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, "
We couldn't find the content you were looking for.