From ee62bee3315d5d5addfb6dd213c9aeeff6673fea Mon Sep 17 00:00:00 2001 From: moecinnamo Date: Sun, 7 Sep 2025 14:21:48 +0800 Subject: [PATCH] the first version --- .env.example | 9 ++ .github/build-and-test.yml | 37 +++++ api.go | 254 +++++++++++++++++++++++++++++++++ cache/.gitignore | 1 + file.go | 108 ++++++++++++++ files/.gitignore | 1 + go.mod | 5 + helper.go | 283 +++++++++++++++++++++++++++++++++++++ httperr.go | 16 +++ index.go | 17 +++ main.go | 81 +++++++++++ sources.json.example | 20 +++ struct.go | 15 ++ yuafengfreeapi.go | 149 +++++++++++++++++++ 14 files changed, 996 insertions(+) create mode 100644 .env.example create mode 100644 .github/build-and-test.yml create mode 100644 api.go create mode 100644 cache/.gitignore create mode 100644 file.go create mode 100644 files/.gitignore create mode 100644 go.mod create mode 100644 helper.go create mode 100644 httperr.go create mode 100644 index.go create mode 100644 main.go create mode 100644 sources.json.example create mode 100644 struct.go create mode 100644 yuafengfreeapi.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad04aca --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +WEBSITE_NAME=MeowEmbeddedMusicServer // Your website name +WEBSITE_URL=http://127.0.0.1:2233 // Your website URL +PORT=2233 // Your website port + +// Yuafeng free API sources +API_SOURCES=kuwo +API_SOURCES_1=netease +API_SOURCES_2=migu +API_SOURCES_3=baidu \ No newline at end of file diff --git a/.github/build-and-test.yml b/.github/build-and-test.yml new file mode 100644 index 0000000..52271c6 --- /dev/null +++ b/.github/build-and-test.yml @@ -0,0 +1,37 @@ +name: build_and_test + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Update System + run: | + sudo apt update && sudo apt upgrade -y + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Install Modules + run: go mod tidy + + - name: Test + run: go test -coverprofile=coverage.txt -v ./... + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + #token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.txt + flags: unittests + name: codecov-umbrella + fail_ci_if_error: true diff --git a/api.go b/api.go new file mode 100644 index 0000000..6a5833e --- /dev/null +++ b/api.go @@ -0,0 +1,254 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" +) + +// APIHandler handles API requests. +func apiHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "MeowMusicEmbeddedServer") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + queryParams := r.URL.Query() + fmt.Printf("[Web Access] Handling request for %s?%s\n", r.URL.Path, queryParams.Encode()) + song := queryParams.Get("song") + singer := queryParams.Get("singer") + + ip, err := IPhandler(r) + if err != nil { + ip = "0.0.0.0" + } + + // Attempt to retrieve music items from sources.json + sources := readSources() + + var musicItem MusicItem + var found bool = false + + for _, source := range sources { + if source.Title == song { + if singer == "" || source.Artist == singer { + musicItem = MusicItem{ + Title: source.Title, + Artist: source.Artist, + AudioURL: source.AudioURL, + M3U8URL: source.M3U8URL, + LyricURL: source.LyricURL, + CoverURL: source.CoverURL, + Duration: source.Duration, + FromCache: false, + } + found = true + break + } + } + } + + // If not found in sources.json, attempt to retrieve from local folder + if !found { + musicItem = getLocalMusicItem(song, singer) + musicItem.FromCache = false + if musicItem.Title != "" { + found = true + } + } + + // If still not found, attempt to retrieve from cache file + if !found { + fmt.Println("[Info] Reading music from cache.") + // Fuzzy matching for singer and song + files, err := filepath.Glob("./cache/*.json") + if err != nil { + fmt.Println("[Error] Error reading cache directory:", err) + return + } + for _, file := range files { + if strings.Contains(filepath.Base(file), song) && (singer == "" || strings.Contains(filepath.Base(file), singer)) { + musicItem, found = readFromCache(file) + if found { + musicItem.FromCache = true + break + } + } + } + } + + // If still not found, request and cache the music item in a separate goroutine + if !found { + fmt.Println("[Info] Updating music item cache from API request.") + go func() { + requestAndCacheMusic(song, singer) + fmt.Println("[Info] Music item cache updated.") + }() + } + + // If still not found, return an empty MusicItem + if !found { + musicItem = MusicItem{ + FromCache: false, + IP: ip, + } + } else { + musicItem.IP = ip + } + + json.NewEncoder(w).Encode(musicItem) +} + +// Read sources.json file and return a list of SourceItem. +func readSources() []MusicItem { + data, err := ioutil.ReadFile("./sources.json") + fmt.Println("[Info] Reading local sources.json") + if err != nil { + fmt.Println("[Error] Failed to read sources.json:", err) + return nil + } + + var sources []MusicItem + err = json.Unmarshal(data, &sources) + if err != nil { + fmt.Println("[Error] Failed to parse sources.json:", err) + return nil + } + + return sources +} + +// Retrieve music items from local folder +func getLocalMusicItem(song, singer string) MusicItem { + musicDir := "./files/music" + fmt.Println("[Info] Reading local folder music.") + files, err := ioutil.ReadDir(musicDir) + if err != nil { + fmt.Println("[Error] Failed to read local music directory:", err) + return MusicItem{} + } + + for _, file := range files { + if file.IsDir() { + if singer == "" { + if strings.Contains(file.Name(), song) { + dirPath := filepath.Join(musicDir, file.Name()) + // Extract artist and title from the directory name + parts := strings.SplitN(file.Name(), "-", 2) + if len(parts) != 2 { + continue // Skip if the directory name doesn't contain exactly one "-" + } + artist := parts[0] + title := parts[1] + musicItem := MusicItem{ + Title: title, + Artist: artist, + AudioURL: "", + AudioFullURL: "", + M3U8URL: "", + LyricURL: "", + CoverURL: "", + Duration: 0, + } + + musicFilePath := filepath.Join(dirPath, "music.mp3") + if _, err := os.Stat(musicFilePath); err == nil { + musicItem.AudioURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/music.mp3" + musicItem.Duration = getMusicDuration(musicFilePath) + } + + for _, audioFormat := range []string{"music_full.mp3", "music_full.flac", "music_full.wav", "music_full.aac", "music_full.ogg"} { + audioFilePath := filepath.Join(dirPath, audioFormat) + if _, err := os.Stat(audioFilePath); err == nil { + musicItem.AudioFullURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/" + audioFormat + break + } + } + + m3u8FilePath := filepath.Join(dirPath, "music.m3u8") + if _, err := os.Stat(m3u8FilePath); err == nil { + musicItem.M3U8URL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/music.m3u8" + } + + lyricFilePath := filepath.Join(dirPath, "lyric.lrc") + if _, err := os.Stat(lyricFilePath); err == nil { + musicItem.LyricURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/lyric.lrc" + } + + coverJpgFilePath := filepath.Join(dirPath, "cover.jpg") + if _, err := os.Stat(coverJpgFilePath); err == nil { + musicItem.CoverURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/cover.jpg" + } else { + coverPngFilePath := filepath.Join(dirPath, "cover.png") + if _, err := os.Stat(coverPngFilePath); err == nil { + musicItem.CoverURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/cover.png" + } + } + + return musicItem + } + } else { + if strings.Contains(file.Name(), singer) && strings.Contains(file.Name(), song) { + dirPath := filepath.Join(musicDir, file.Name()) + // Extract artist and title from the directory name + parts := strings.SplitN(file.Name(), "-", 2) + if len(parts) != 2 { + continue // Skip if the directory name doesn't contain exactly one "-" + } + artist := parts[0] + title := parts[1] + musicItem := MusicItem{ + Title: title, + Artist: artist, + AudioURL: "", + AudioFullURL: "", + M3U8URL: "", + LyricURL: "", + CoverURL: "", + Duration: 0, + } + + musicFilePath := filepath.Join(dirPath, "music.mp3") + if _, err := os.Stat(musicFilePath); err == nil { + musicItem.AudioURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/music.mp3" + musicItem.Duration = getMusicDuration(musicFilePath) + } + + for _, audioFormat := range []string{"music_full.mp3", "music_full.flac", "music_full.wav", "music_full.aac", "music_full.ogg"} { + audioFilePath := filepath.Join(dirPath, audioFormat) + if _, err := os.Stat(audioFilePath); err == nil { + musicItem.AudioFullURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/" + audioFormat + break + } + } + + m3u8FilePath := filepath.Join(dirPath, "music.m3u8") + if _, err := os.Stat(m3u8FilePath); err == nil { + musicItem.M3U8URL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/music.m3u8" + } + + lyricFilePath := filepath.Join(dirPath, "lyric.lrc") + if _, err := os.Stat(lyricFilePath); err == nil { + musicItem.LyricURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/lyric.lrc" + } + + coverJpgFilePath := filepath.Join(dirPath, "cover.jpg") + if _, err := os.Stat(coverJpgFilePath); err == nil { + musicItem.CoverURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/cover.jpg" + } else { + coverPngFilePath := filepath.Join(dirPath, "cover.png") + if _, err := os.Stat(coverPngFilePath); err == nil { + musicItem.CoverURL = os.Getenv("WEBSITE_URL") + "/music/" + file.Name() + "/cover.png" + } + } + + return musicItem + } + } + } + } + + return MusicItem{} // If no matching folder is found, return an empty MusicItem +} diff --git a/cache/.gitignore b/cache/.gitignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cache/.gitignore @@ -0,0 +1 @@ + diff --git a/file.go b/file.go new file mode 100644 index 0000000..e51099b --- /dev/null +++ b/file.go @@ -0,0 +1,108 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" +) + +// ListFiles function: Traverse all files in the specified directory and return a slice of the file path +func ListFiles(dir string) ([]string, error) { + var files []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, path) + } + return nil + }) + return files, err +} + +// Get Content function: Read the content of a specified file and return it +func GetFileContent(filePath string) ([]byte, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // Get File Size + fileInfo, err := file.Stat() + if err != nil { + return nil, err + } + fileSize := fileInfo.Size() + + // Read File Content + fileContent := make([]byte, fileSize) + _, err = file.Read(fileContent) + if err != nil { + return nil, err + } + + return fileContent, nil +} + +// fileHandler function: Handle file requests +func fileHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "MeowMusicEmbeddedServer") + // Obtain the path of the request + filePath := r.URL.Path + + // Construct the complete file path + fullFilePath := filepath.Join("./files", filePath) + + // Get file content + fileContent, err := GetFileContent(fullFilePath) + if err != nil { + NotFoundHandler(w, r) + return + } + + // Set appropriate Content-Type based on file extension + ext := filepath.Ext(filePath) + switch ext { + case ".mp3": + w.Header().Set("Content-Type", "audio/mpeg") + case ".wav": + w.Header().Set("Content-Type", "audio/wav") + case ".flac": + w.Header().Set("Content-Type", "audio/flac") + case ".aac": + w.Header().Set("Content-Type", "audio/aac") + case ".ogg": + w.Header().Set("Content-Type", "audio/ogg") + case ".m4a": + w.Header().Set("Content-Type", "audio/mp4") + case ".amr": + w.Header().Set("Content-Type", "audio/amr") + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".gif": + w.Header().Set("Content-Type", "image/gif") + case ".bmp": + w.Header().Set("Content-Type", "image/bmp") + case ".svg": + w.Header().Set("Content-Type", "image/svg+xml") + case ".webp": + w.Header().Set("Content-Type", "image/webp") + case ".txt": + w.Header().Set("Content-Type", "text/plain") + case ".lrc": + w.Header().Set("Content-Type", "text/plain") + case ".mrc": + w.Header().Set("Content-Type", "text/plain") + case ".json": + w.Header().Set("Content-Type", "application/json") + default: + w.Header().Set("Content-Type", "application/octet-stream") + } + + // Write file content to response + w.Write(fileContent) +} diff --git a/files/.gitignore b/files/.gitignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/files/.gitignore @@ -0,0 +1 @@ + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c20fedd --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module MeowEmbedded-MusicServer + +go 1.25.0 + +require github.com/joho/godotenv v1.5.1 diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..f66517c --- /dev/null +++ b/helper.go @@ -0,0 +1,283 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// Helper function to compress and segment audio file +func compressAndSegmentAudio(inputFile, outputDir string) error { + fmt.Printf("[Info] Compress and segment audio file %s\n", inputFile) + // Compress music files + outputFile := filepath.Join(outputDir, "music.mp3") + cmd := exec.Command("ffmpeg", "-i", inputFile, "-ac", "1", "-ab", "32k", "-ar", "16000", outputFile) + err := cmd.Run() + if err != nil { + return err + } + + // Split music files + chunkDir := filepath.Join(outputDir, "chunk") + err = os.MkdirAll(chunkDir, 0755) + if err != nil { + return err + } + + // Using ffmpeg for segmentation + segmentedFilePattern := filepath.Join(chunkDir, "%03d.mp3") // e.g. 001.mp3, 002.mp3, ... + cmd = exec.Command("ffmpeg", "-i", outputFile, "-ac", "1", "-ab", "32k", "-ar", "16000", "-f", "segment", "-segment_time", "10", segmentedFilePattern) + err = cmd.Run() + if err != nil { + return err + } + + return nil +} + +// Helper function to create M3U8 playlist file +func createM3U8Playlist(outputDir string) error { + fmt.Printf("[Info] Create M3U8 playlist file for %s\n", outputDir) + playlistFile := filepath.Join(outputDir, "music.m3u8") + file, err := os.Create(playlistFile) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString("#EXTM3U\n") + if err != nil { + return err + } + _, err = file.WriteString("#EXT-X-VERSION:3\n") + if err != nil { + return err + } + _, err = file.WriteString("#EXT-X-TARGETDURATION:10\n") + if err != nil { + return err + } + + chunkDir := filepath.Join(outputDir, "chunk") + files, err := ioutil.ReadDir(chunkDir) + if err != nil { + return err + } + + var chunkFiles []string + for _, file := range files { + if strings.HasSuffix(file.Name(), ".mp3") { + chunkFiles = append(chunkFiles, file.Name()) + } + } + + // Sort by file name + for i := 0; i < len(chunkFiles); i++ { + for j := i + 1; j < len(chunkFiles); j++ { + if chunkFiles[i] > chunkFiles[j] { + chunkFiles[i], chunkFiles[j] = chunkFiles[j], chunkFiles[i] + } + } + } + + for _, chunkFile := range chunkFiles { + _, err = file.WriteString("#EXTINF:10.000\n") + if err != nil { + return err + } + url := fmt.Sprintf("%s/cache/music/%s/%s/%s\n", os.Getenv("WEBSITE_URL"), filepath.Base(outputDir), "chunk", chunkFile) + _, err = file.WriteString(url) + } + + return err +} + +// Helper function to download files from URL +func downloadFile(filename string, url string) error { + fmt.Printf("[Info] Download file %s from URL %s\n", filename, url) + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + out, err := os.Create(filename) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err +} + +// Helper function to get duration of obtaining music files +func getMusicDuration(filePath string) int { + fmt.Printf("[Info] Get duration of obtaining music file %s\n", filePath) + // Use ffprobe to get audio duration + output, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filePath).Output() + if err != nil { + fmt.Println("[Error] Error getting audio duration:", err) + return 0 + } + + duration, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64) + if err != nil { + fmt.Println("[Error] Error converting duration to float:", err) + return 0 + } + + return int(duration) +} + +// Function for identifying file formats +func getMusicFileExtension(url string) (string, error) { + resp, err := http.Head(url) + if err != nil { + return "", err + } + // Get file format from Content-Type header + contentType := resp.Header.Get("Content-Type") + ext, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "", err + } + // Identify file extension based on file format + switch ext { + case "audio/mpeg": + return ".mp3", nil + case "audio/flac": + return ".flac", nil + case "audio/x-flac": + return ".flac", nil + case "audio/wav": + return ".wav", nil + case "audio/aac": + return ".aac", nil + case "audio/ogg": + return ".ogg", nil + case "application/octet-stream": + // Try to guess file format from URL or other information + if strings.Contains(url, ".mp3") { + return ".mp3", nil + } else if strings.Contains(url, ".flac") { + return ".flac", nil + } else if strings.Contains(url, ".wav") { + return ".wav", nil + } else if strings.Contains(url, ".aac") { + return ".aac", nil + } else if strings.Contains(url, ".ogg") { + return ".ogg", nil + } else { + return "", fmt.Errorf("unknown file format from Content-Type and URL: %s", contentType) + } + default: + return "", fmt.Errorf("unknown file format: %s", ext) + } +} + +// Helper function to request and cache music from API sources +func requestAndCacheMusic(song, singer string) { + fmt.Printf("[Info] Requesting and caching music for %s", song) + // Create cache directory if it doesn't exist + err := os.MkdirAll("./cache", 0755) + if err != nil { + fmt.Println("[Error] Error creating cache directory:", err) + return + } + + // Get API_SOURCES and any subsequent environment variables (e.g. API_SOURCES_1, API_SOURCES_2, etc.) + var sources []string + for i := 0; ; i++ { + var key string + if i == 0 { + key = "API_SOURCES" + } else { + key = "API_SOURCES_" + strconv.Itoa(i) + } + source := os.Getenv(key) + if source == "" { + break + } + sources = append(sources, source) + } + + // Request and cache music from each source in turn + var musicItem MusicItem + for _, source := range sources { + fmt.Printf("[Info] Requesting music from source: %s\n", source) + musicItem = YuafengAPIResponseHandler(strings.TrimSpace(source), song, singer) + if musicItem.Title != "" { + // If music item is valid, stop searching for sources + break + } + } + + // If no valid music item was found, return an empty MusicItem + if musicItem.Title == "" { + fmt.Println("[Warning] No valid music item retrieved.") + return + } + + // Create cache file path based on artist and title + cacheFile := fmt.Sprintf("./cache/%s-%s.json", musicItem.Artist, musicItem.Title) + + // Write cache data to cache file + cacheData, err := json.MarshalIndent(musicItem, "", " ") + if err != nil { + fmt.Println("[Error] Error marshalling cache data:", err) + return + } + err = ioutil.WriteFile(cacheFile, cacheData, 0644) + if err != nil { + fmt.Println("[Error] Error writing cache file:", err) + return + } + + fmt.Println("[Info] Music request and caching completed successfully.") +} + +// Helper function to read music data from cache file +func readFromCache(filePath string) (MusicItem, bool) { + data, err := ioutil.ReadFile(filePath) + if err != nil { + fmt.Println("[Error] Failed to read cache file:", err) + return MusicItem{}, false + } + + var musicItem MusicItem + err = json.Unmarshal(data, &musicItem) + if err != nil { + fmt.Println("[Error] Failed to parse cache file:", err) + return MusicItem{}, false + } + + return musicItem, true +} + +// Helper function to obtain IP address of the client +func IPhandler(r *http.Request) (string, error) { + ip := r.Header.Get("X-Real-IP") + if ip != "" { + return ip, nil + } + ip = r.Header.Get("X-Forwarded-For") + if ip != "" { + ips := strings.Split(ip, ",") + return strings.TrimSpace(ips[0]), nil + } + ip = r.RemoteAddr + if ip != "" { + return strings.Split(ip, ":")[0], nil + } + + return "", fmt.Errorf("unable to obtain IP address information") +} diff --git a/httperr.go b/httperr.go new file mode 100644 index 0000000..328e886 --- /dev/null +++ b/httperr.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "net/http" + "os" +) + +func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + home_url := os.Getenv("HOME_URL") + w.WriteHeader(http.StatusNotFound) + w.Header().Set("Server", "MeowMusicServer") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, "404 Music Lost!

404 Music Lost!

We couldn't find the content you were looking for.

404
") + fmt.Fprintf(w, "", home_url) +} diff --git a/index.go b/index.go new file mode 100644 index 0000000..0227c13 --- /dev/null +++ b/index.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "net/http" +) + +func indexHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "MeowMusicEmbeddedServer") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Printf("[Web Access] Handling request for %s\n", r.URL.Path) + if r.URL.Path != "/" { + fileHandler(w, r) + return + } + fmt.Fprintf(w, "

音乐服务器

") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ab67a8e --- /dev/null +++ b/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" +) + +const ( + TAG = "MeowEmbeddedMusicServer" +) + +func main() { + err := godotenv.Load() + if err != nil { + fmt.Printf("[Warning] %s Loading .env file failed: %v\nUse the default configuration instead.\n", TAG, err) + } + + port := os.Getenv("PORT") + if port == "" { + fmt.Printf("[Warning] %s PORT environment variable not set\nUse the default port 2233 instead.\n", TAG) + port = "2233" + } + + http.HandleFunc("/", indexHandler) + http.HandleFunc("/stream_pcm", apiHandler) + fmt.Printf("[Info] %s Started.\n喵波音律-音乐家园QQ交流群:865754861\n", TAG) + fmt.Printf("[Info] Starting music server at port %s\n", port) + + // Create a channel to listen for signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Create a server instance + srv := &http.Server{ + Addr: ":" + port, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + // Start the server + go func() { + if err := srv.ListenAndServe(); err != nil { + fmt.Println(err) + sigChan <- syscall.SIGINT // Send a signal to shut down the server + } + }() + + // Create a channel to listen for standard input + exitChan := make(chan struct{}) + + go func() { + for { + var input string + fmt.Scanln(&input) + if input == "exit" { + exitChan <- struct{}{} + return + } + } + }() + + // Monitor signals or exit signals from standard inputs + select { + case <-sigChan: + fmt.Printf("[Info] Server is shutting down.\nGoodbye!\n") + case <-exitChan: + fmt.Printf("[Info] Server is shutting down.\nGoodbye!\n") + } + + // Shut down the server + if err := srv.Shutdown(context.Background()); err != nil { + fmt.Println(err) + } +} diff --git a/sources.json.example b/sources.json.example new file mode 100644 index 0000000..7f359b1 --- /dev/null +++ b/sources.json.example @@ -0,0 +1,20 @@ +[ + { + "title": "", + "artist": "", + "audio_url": "", + "m3u8_url": "", + "lyric_url": "", + "cover_url": "", + "duration": 0 + }, + { + "title": "", + "artist": "", + "audio_url": "", + "m3u8_url": "", + "lyric_url": "", + "cover_url": "", + "duration": 0 + } +] \ No newline at end of file diff --git a/struct.go b/struct.go new file mode 100644 index 0000000..3d972cf --- /dev/null +++ b/struct.go @@ -0,0 +1,15 @@ +package main + +// MusicItem represents a music item. +type MusicItem struct { + Title string `json:"title"` + Artist string `json:"artist"` + AudioURL string `json:"audio_url"` + AudioFullURL string `json:"audio_full_url"` + M3U8URL string `json:"m3u8_url"` + LyricURL string `json:"lyric_url"` + CoverURL string `json:"cover_url"` + Duration int `json:"duration"` + FromCache bool `json:"from_cache"` + IP string `json:"ip"` +} diff --git a/yuafengfreeapi.go b/yuafengfreeapi.go new file mode 100644 index 0000000..bd981e1 --- /dev/null +++ b/yuafengfreeapi.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" +) + +type YuafengAPIFreeResponse struct { + Data struct { + Song string `json:"song"` + Singer string `json:"singer"` + Cover string `json:"cover"` + AlbumName string `json:"album_name"` + Music string `json:"music"` + Lyric string `json:"lyric"` + } `json:"data"` +} + +// 枫雨API response handler. +func YuafengAPIResponseHandler(sources, song, singer string) MusicItem { + fmt.Printf("[Info] Fetching music data from 枫林 free API for %s by %s\n", song, singer) + var url string + switch sources { + case "kuwo": + url = "https://api.yuafeng.cn/API/ly/kwmusic.php" + case "netease": + url = "https://api.yuafeng.cn/API/ly/wymusic.php" + case "migu": + url = "https://api.yuafeng.cn/API/ly/mgmusic.php" + case "baidu": + url = "https://api.yuafeng.cn/API/ly/bdmusic.php" + default: + return MusicItem{} + } + resp, err := http.Get(url + "?msg=" + song + "&n=1") + if err != nil { + fmt.Println("[Error] Error fetching the data from Yuafeng free API:", err) + return MusicItem{} + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Println("[Error] Error reading the response body from Yuafeng free API:", err) + return MusicItem{} + } + var response YuafengAPIFreeResponse + err = json.Unmarshal(body, &response) + if err != nil { + fmt.Println("[Error] Error unmarshalling the data from Yuafeng free API:", err) + return MusicItem{} + } + + // Create directory + dirName := fmt.Sprintf("./files/cache/music/%s-%s", response.Data.Singer, response.Data.Song) + err = os.MkdirAll(dirName, 0755) + if err != nil { + fmt.Println("[Error] Error creating directory:", err) + return MusicItem{} + } + + if response.Data.Music == "" { + fmt.Println("[Warning] Music URL is empty") + return MusicItem{} + } + + // Identify music file format + musicExt, err := getMusicFileExtension(response.Data.Music) + if err != nil { + fmt.Println("[Error] Error identifying music file format:", err) + return MusicItem{} + } + + // Download music files + err = downloadFile(filepath.Join(dirName, "music_full"+musicExt), response.Data.Music) + if err != nil { + fmt.Println("[Error] Error downloading music file:", err) + } + + // Retrieve music file duration + musicFilePath := filepath.Join(dirName, "music_full"+musicExt) + duration := getMusicDuration(musicFilePath) + + // Download cover image + ext := filepath.Ext(response.Data.Cover) + err = downloadFile(filepath.Join(dirName, "cover"+ext), response.Data.Cover) + if err != nil { + fmt.Println("[Error] Error downloading cover image:", err) + } + + // Check if the lyrics format is in link format + lyricData := response.Data.Lyric + if lyricData == "获取歌词失败" { + // If it is "获取歌词失败", do nothing + fmt.Println("[Warning] Lyric retrieval failed, skipping lyric file creation and download.") + } else if !strings.HasPrefix(lyricData, "http://") && !strings.HasPrefix(lyricData, "https://") { + // If it is not in link format, write the lyrics to the file line by line + lines := strings.Split(lyricData, "\r\n") + lyricFilePath := filepath.Join(dirName, "lyric.lrc") + file, err := os.Create(lyricFilePath) + if err != nil { + fmt.Println("[Error] Error creating lyric file:", err) + return MusicItem{} + } + defer file.Close() + + for _, line := range lines { + _, err := file.WriteString(line + "\r\n") + if err != nil { + fmt.Println("[Error] Error writing to lyric file:", err) + return MusicItem{} + } + } + } else { + // If it is in link format, download the lyrics file + err = downloadFile(filepath.Join(dirName, "lyric.lrc"), lyricData) + if err != nil { + fmt.Println("[Error] Error downloading lyric file:", err) + } + } + + // Compress and segment audio file + err = compressAndSegmentAudio(filepath.Join(dirName, "music_full"+musicExt), dirName) + if err != nil { + fmt.Println("[Error] Error compressing and segmenting audio:", err) + } + + // Create m3u8 playlist + err = createM3U8Playlist(dirName) + if err != nil { + fmt.Println("[Error] Error creating m3u8 playlist:", err) + } + + websiteURL := os.Getenv("WEBSITE_URL") + return MusicItem{ + Title: response.Data.Song, + Artist: response.Data.Singer, + CoverURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/cover" + ext, + LyricURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/lyric.lrc", + AudioFullURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music_full" + musicExt, + AudioURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.mp3", + M3U8URL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.m3u8", + Duration: duration, + } +}