improve functionality

This commit is contained in:
2025-09-11 17:15:39 +08:00
parent a5912bb302
commit ee785ae4a6
11 changed files with 442 additions and 216 deletions

View File

@@ -10,6 +10,7 @@ WEBSITE_FAVICON=/favicon.ico # Your website favicon
WEBSITE_BACKGROUND=/background.webp # Your website background image WEBSITE_BACKGROUND=/background.webp # Your website background image
WEBSITE_URL=http://127.0.0.1:2233 # Your website URL WEBSITE_URL=http://127.0.0.1:2233 # Your website URL
EMBEDDED_WEBSITE_URL=http://127.0.0.1:2233 # Your embedded website URL
PORT=2233 # Your website port PORT=2233 # Your website port
FONTAWESOME_CDN=https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css # Fontawesome CDN FONTAWESOME_CDN=https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css # Fontawesome CDN

View File

@@ -25,13 +25,4 @@ jobs:
run: go mod tidy run: go mod tidy
- name: Test - name: Test
run: go test -coverprofile=coverage.txt -v ./... run: go test -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

View File

@@ -1,4 +1,23 @@
# MeowEmbeddedMusicServer # MeowEmbeddedMusicServer
[![codecov](https://codecov.io/gh/IntelligentlyEverything/MeowEmbeddedMusicServer/graph/badge.svg)](https://codecov.io/gh/IntelligentlyEverything/MeowEmbeddedMusicServer) [English](README.md) | [简体中文](README_zh-CN.md)
Your Embedded Music Server for you. Your Embedded Music Server for you.
## Features
- Play music from your server
- Music streaming for your embedded devices
- Music library management
- Music search and cache
# Tutorial document
Please refer to the [wiki](https://github.com/IntelligentlyEverything/MeowEmbeddedMusicServer/wiki).
## Star History
<a href="https://star-history.com/#IntelligentlyEverything/MeowEmbeddedMusicServer&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date" />
</picture>
</a>

25
README_zh-CN.md Normal file
View File

@@ -0,0 +1,25 @@
# Meow 为嵌入式设备制作的音乐串流服务
[English](README.md) | [简体中文](README_zh-CN.md)
MeowEmbeddedMusicServer 是一个为嵌入式设备制作的音乐串流服务。
它可以播放来自你的服务器的音乐,也可以为你的嵌入式设备提供音乐流媒体服务。
它还可以管理音乐库,并且可以搜索和下载音乐。
## 特性
- 在线听音乐
- 为嵌入式设备提供音乐串流服务
- 管理音乐库
- 搜索和缓存音乐
# 教程文档
请参阅 [维基](https://github.com/IntelligentlyEverything/MeowEmbeddedMusicServer/wiki).
## Star 历史
<a href="https://star-history.com/#IntelligentlyEverything/MeowEmbeddedMusicServer&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=IntelligentlyEverything/MeowEmbeddedMusicServer&type=Date" />
</picture>
</a>

268
api.go
View File

@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os" "net/url"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@@ -24,24 +24,79 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
ip = "0.0.0.0" ip = "0.0.0.0"
} }
if song == "" {
musicItem := MusicItem{
FromCache: false,
IP: ip,
}
json.NewEncoder(w).Encode(musicItem)
return
}
// Attempt to retrieve music items from sources.json // Attempt to retrieve music items from sources.json
sources := readSources() sources := readSources()
var musicItem MusicItem var musicItem MusicItem
var found bool = false var found bool = false
// Build request scheme
var scheme string
if r.TLS == nil {
scheme = "http"
} else {
scheme = "https"
}
for _, source := range sources { for _, source := range sources {
if source.Title == song { if source.Title == song {
if singer == "" || source.Artist == singer { if singer == "" || source.Artist == singer {
// Determine the protocol for each URL and build accordingly
var audioURL, audioFullURL, m3u8URL, lyricURL, coverURL string
if strings.HasPrefix(source.AudioURL, "http://") {
audioURL = scheme + "://" + r.Host + "/url/http/" + url.QueryEscape(strings.TrimPrefix(source.AudioURL, "http://"))
} else if strings.HasPrefix(source.AudioURL, "https://") {
audioURL = scheme + "://" + r.Host + "/url/https/" + url.QueryEscape(strings.TrimPrefix(source.AudioURL, "https://"))
} else {
audioURL = scheme + "://" + r.Host + "/" + url.QueryEscape(source.AudioURL)
}
if strings.HasPrefix(source.AudioFullURL, "http://") {
audioFullURL = scheme + "://" + r.Host + "/url/http/" + url.QueryEscape(strings.TrimPrefix(source.AudioFullURL, "http://"))
} else if strings.HasPrefix(source.AudioFullURL, "https://") {
audioFullURL = scheme + "://" + r.Host + "/url/https/" + url.QueryEscape(strings.TrimPrefix(source.AudioFullURL, "https://"))
} else {
audioFullURL = scheme + "://" + r.Host + "/" + url.QueryEscape(source.AudioFullURL)
}
if strings.HasPrefix(source.M3U8URL, "http://") {
m3u8URL = scheme + "://" + r.Host + "/url/http/" + url.QueryEscape(strings.TrimPrefix(source.M3U8URL, "http://"))
} else if strings.HasPrefix(source.M3U8URL, "https://") {
m3u8URL = scheme + "://" + r.Host + "/url/https/" + url.QueryEscape(strings.TrimPrefix(source.M3U8URL, "https://"))
} else {
m3u8URL = scheme + "://" + r.Host + "/" + url.QueryEscape(source.M3U8URL)
}
if strings.HasPrefix(source.LyricURL, "http://") {
lyricURL = scheme + "://" + r.Host + "/url/http/" + url.QueryEscape(strings.TrimPrefix(source.LyricURL, "http://"))
} else if strings.HasPrefix(source.LyricURL, "https://") {
lyricURL = scheme + "://" + r.Host + "/url/https/" + url.QueryEscape(strings.TrimPrefix(source.LyricURL, "https://"))
} else {
lyricURL = scheme + "://" + r.Host + "/" + url.QueryEscape(source.LyricURL)
}
if strings.HasPrefix(source.CoverURL, "http://") {
coverURL = scheme + "://" + r.Host + "/url/http/" + url.QueryEscape(strings.TrimPrefix(source.CoverURL, "http://"))
} else if strings.HasPrefix(source.CoverURL, "https://") {
coverURL = scheme + "://" + r.Host + "/url/https/" + url.QueryEscape(strings.TrimPrefix(source.CoverURL, "https://"))
} else {
coverURL = scheme + "://" + r.Host + "/" + url.QueryEscape(source.CoverURL)
}
musicItem = MusicItem{ musicItem = MusicItem{
Title: source.Title, Title: source.Title,
Artist: source.Artist, Artist: source.Artist,
AudioURL: source.AudioURL, AudioURL: audioURL,
M3U8URL: source.M3U8URL, AudioFullURL: audioFullURL,
LyricURL: source.LyricURL, M3U8URL: m3u8URL,
CoverURL: source.CoverURL, LyricURL: lyricURL,
Duration: source.Duration, CoverURL: coverURL,
FromCache: false, Duration: source.Duration,
FromCache: false,
} }
found = true found = true
break break
@@ -54,6 +109,21 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
musicItem = getLocalMusicItem(song, singer) musicItem = getLocalMusicItem(song, singer)
musicItem.FromCache = false musicItem.FromCache = false
if musicItem.Title != "" { if musicItem.Title != "" {
if musicItem.AudioURL != "" {
musicItem.AudioURL = scheme + "://" + r.Host + musicItem.AudioURL
}
if musicItem.AudioFullURL != "" {
musicItem.AudioFullURL = scheme + "://" + r.Host + musicItem.AudioFullURL
}
if musicItem.M3U8URL != "" {
musicItem.M3U8URL = scheme + "://" + r.Host + musicItem.M3U8URL
}
if musicItem.LyricURL != "" {
musicItem.LyricURL = scheme + "://" + r.Host + musicItem.LyricURL
}
if musicItem.CoverURL != "" {
musicItem.CoverURL = scheme + "://" + r.Host + musicItem.CoverURL
}
found = true found = true
} }
} }
@@ -71,6 +141,21 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
if strings.Contains(filepath.Base(file), song) && (singer == "" || strings.Contains(filepath.Base(file), singer)) { if strings.Contains(filepath.Base(file), song) && (singer == "" || strings.Contains(filepath.Base(file), singer)) {
musicItem, found = readFromCache(file) musicItem, found = readFromCache(file)
if found { if found {
if musicItem.AudioURL != "" {
musicItem.AudioURL = scheme + "://" + r.Host + musicItem.AudioURL
}
if musicItem.AudioFullURL != "" {
musicItem.AudioFullURL = scheme + "://" + r.Host + musicItem.AudioFullURL
}
if musicItem.M3U8URL != "" {
musicItem.M3U8URL = scheme + "://" + r.Host + musicItem.M3U8URL
}
if musicItem.LyricURL != "" {
musicItem.LyricURL = scheme + "://" + r.Host + musicItem.LyricURL
}
if musicItem.CoverURL != "" {
musicItem.CoverURL = scheme + "://" + r.Host + musicItem.CoverURL
}
musicItem.FromCache = true musicItem.FromCache = true
break break
} }
@@ -81,10 +166,15 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
// If still not found, request and cache the music item in a separate goroutine // If still not found, request and cache the music item in a separate goroutine
if !found { if !found {
fmt.Println("[Info] Updating music item cache from API request.") fmt.Println("[Info] Updating music item cache from API request.")
go func() { musicItem = requestAndCacheMusic(song, singer)
requestAndCacheMusic(song, singer) fmt.Println("[Info] Music item cache updated.")
fmt.Println("[Info] Music item cache updated.") musicItem.FromCache = false
}() musicItem.AudioURL = scheme + "://" + r.Host + musicItem.AudioURL
musicItem.AudioFullURL = scheme + "://" + r.Host + musicItem.AudioFullURL
musicItem.M3U8URL = scheme + "://" + r.Host + musicItem.M3U8URL
musicItem.LyricURL = scheme + "://" + r.Host + musicItem.LyricURL
musicItem.CoverURL = scheme + "://" + r.Host + musicItem.CoverURL
found = true
} }
// If still not found, return an empty MusicItem // If still not found, return an empty MusicItem
@@ -99,155 +189,3 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(musicItem) json.NewEncoder(w).Encode(musicItem)
} }
// Read sources.json file and return a list of SourceItem.
func readSources() []MusicItem {
data, err := os.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 := os.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
}

93
file.go
View File

@@ -1,9 +1,12 @@
package main package main
import ( import (
"io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// ListFiles function: Traverse all files in the specified directory and return a slice of the file path // ListFiles function: Traverse all files in the specified directory and return a slice of the file path
@@ -52,6 +55,96 @@ func fileHandler(w http.ResponseWriter, r *http.Request) {
// Obtain the path of the request // Obtain the path of the request
filePath := r.URL.Path filePath := r.URL.Path
// Check if the request path starts with "/url/"
if strings.HasPrefix(filePath, "/url/") {
// Extract the URL after "/url/"
urlPath := filePath[len("/url/"):]
// Decode the URL path in case it's URL encoded
decodedURL, err := url.QueryUnescape(urlPath)
if err != nil {
NotFoundHandler(w, r)
return
}
// Determine the protocol based on the URL path
var protocol string
if strings.HasPrefix(decodedURL, "http/") {
protocol = "http://"
} else if strings.HasPrefix(decodedURL, "https/") {
protocol = "https://"
} else {
NotFoundHandler(w, r)
return
}
// Remove the protocol part from the decoded URL
decodedURL = strings.TrimPrefix(decodedURL, "http/")
decodedURL = strings.TrimPrefix(decodedURL, "https/")
// Correctly concatenate the protocol with the decoded URL
decodedURL = protocol + decodedURL
// Create a new HTTP request to the decoded URL, without copying headers
req, err := http.NewRequest("GET", decodedURL, nil)
if err != nil {
NotFoundHandler(w, r)
return
}
// Send the request and get the response
client := &http.Client{}
resp, err := client.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
NotFoundHandler(w, r)
return
}
defer resp.Body.Close()
// Read the response body into a byte slice
fileContent, err := io.ReadAll(resp.Body)
if err != nil {
NotFoundHandler(w, r)
return
}
// Set appropriate Content-Type based on file extension
ext := filepath.Ext(decodedURL)
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)
return
}
// Construct the complete file path // Construct the complete file path
fullFilePath := filepath.Join("./files", filePath) fullFilePath := filepath.Join("./files", filePath)

208
helper.go
View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"mime" "mime"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -18,7 +19,7 @@ func compressAndSegmentAudio(inputFile, outputDir string) error {
fmt.Printf("[Info] Compress and segment audio file %s\n", inputFile) fmt.Printf("[Info] Compress and segment audio file %s\n", inputFile)
// Compress music files // Compress music files
outputFile := filepath.Join(outputDir, "music.mp3") outputFile := filepath.Join(outputDir, "music.mp3")
cmd := exec.Command("ffmpeg", "-i", inputFile, "-ac", "1", "-ab", "32k", "-ar", "16000", outputFile) cmd := exec.Command("ffmpeg", "-i", inputFile, "-ac", "1", "-ab", "32k", "-ar", "24000", outputFile)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
return err return err
@@ -92,7 +93,7 @@ func createM3U8Playlist(outputDir string) error {
if err != nil { if err != nil {
return err return err
} }
url := fmt.Sprintf("%s/cache/music/%s/%s/%s\n", os.Getenv("WEBSITE_URL"), filepath.Base(outputDir), "chunk", chunkFile) url := fmt.Sprintf("%s/cache/music/%s/%s/%s\n", os.Getenv("EMBEDDED_WEBSITE_URL"), filepath.Base(outputDir), "chunk", chunkFile)
_, err = file.WriteString(url) _, err = file.WriteString(url)
} }
@@ -137,7 +138,7 @@ func getMusicDuration(filePath string) int {
return int(duration) return int(duration)
} }
// Function for identifying file formats // Helper function for identifying file formats
func getMusicFileExtension(url string) (string, error) { func getMusicFileExtension(url string) (string, error) {
resp, err := http.Head(url) resp, err := http.Head(url)
if err != nil { if err != nil {
@@ -183,14 +184,185 @@ func getMusicFileExtension(url string) (string, error) {
} }
} }
// Helper function to obtain music data from local folder
func getLocalMusicItem(song, singer string) MusicItem {
musicDir := "./files/music"
fmt.Println("[Info] Reading local folder music.")
files, err := os.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 = "/music/" + url.QueryEscape(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 = "/music/" + url.QueryEscape(file.Name()) + "/" + audioFormat
break
}
}
m3u8FilePath := filepath.Join(dirPath, "music.m3u8")
if _, err := os.Stat(m3u8FilePath); err == nil {
musicItem.M3U8URL = "/music/" + url.QueryEscape(file.Name()) + "/music.m3u8"
}
lyricFilePath := filepath.Join(dirPath, "lyric.lrc")
if _, err := os.Stat(lyricFilePath); err == nil {
musicItem.LyricURL = "/music/" + url.QueryEscape(file.Name()) + "/lyric.lrc"
}
coverJpgFilePath := filepath.Join(dirPath, "cover.jpg")
if _, err := os.Stat(coverJpgFilePath); err == nil {
musicItem.CoverURL = "/music/" + url.QueryEscape(file.Name()) + "/cover.jpg"
} else {
coverPngFilePath := filepath.Join(dirPath, "cover.png")
if _, err := os.Stat(coverPngFilePath); err == nil {
musicItem.CoverURL = "/music/" + url.QueryEscape(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 = "/music/" + url.QueryEscape(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 = "/music/" + url.QueryEscape(file.Name()) + "/" + audioFormat
break
}
}
m3u8FilePath := filepath.Join(dirPath, "music.m3u8")
if _, err := os.Stat(m3u8FilePath); err == nil {
musicItem.M3U8URL = "/music/" + url.QueryEscape(file.Name()) + "/music.m3u8"
}
lyricFilePath := filepath.Join(dirPath, "lyric.lrc")
if _, err := os.Stat(lyricFilePath); err == nil {
musicItem.LyricURL = "/music/" + url.QueryEscape(file.Name()) + "/lyric.lrc"
}
coverJpgFilePath := filepath.Join(dirPath, "cover.jpg")
if _, err := os.Stat(coverJpgFilePath); err == nil {
musicItem.CoverURL = "/music/" + url.QueryEscape(file.Name()) + "/cover.jpg"
} else {
coverPngFilePath := filepath.Join(dirPath, "cover.png")
if _, err := os.Stat(coverPngFilePath); err == nil {
musicItem.CoverURL = "/music/" + url.QueryEscape(file.Name()) + "/cover.png"
}
}
return musicItem
}
}
}
}
return MusicItem{} // If no matching folder is found, return an empty MusicItem
}
// 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")
}
// Helper function to read music sources from sources.json file
func readSources() []MusicItem {
data, err := os.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
}
// Helper function to request and cache music from API sources // Helper function to request and cache music from API sources
func requestAndCacheMusic(song, singer string) { func requestAndCacheMusic(song, singer string) MusicItem {
fmt.Printf("[Info] Requesting and caching music for %s", song) fmt.Printf("[Info] Requesting and caching music for %s", song)
// Create cache directory if it doesn't exist // Create cache directory if it doesn't exist
err := os.MkdirAll("./cache", 0755) err := os.MkdirAll("./cache", 0755)
if err != nil { if err != nil {
fmt.Println("[Error] Error creating cache directory:", err) fmt.Println("[Error] Error creating cache directory:", err)
return return MusicItem{}
} }
// Get API_SOURCES and any subsequent environment variables (e.g. API_SOURCES_1, API_SOURCES_2, etc.) // Get API_SOURCES and any subsequent environment variables (e.g. API_SOURCES_1, API_SOURCES_2, etc.)
@@ -223,7 +395,7 @@ func requestAndCacheMusic(song, singer string) {
// If no valid music item was found, return an empty MusicItem // If no valid music item was found, return an empty MusicItem
if musicItem.Title == "" { if musicItem.Title == "" {
fmt.Printf("[Warning] No valid music item retrieved.\n") fmt.Printf("[Warning] No valid music item retrieved.\n")
return return MusicItem{}
} }
// Create cache file path based on artist and title // Create cache file path based on artist and title
@@ -233,15 +405,16 @@ func requestAndCacheMusic(song, singer string) {
cacheData, err := json.MarshalIndent(musicItem, "", " ") cacheData, err := json.MarshalIndent(musicItem, "", " ")
if err != nil { if err != nil {
fmt.Println("[Error] Error marshalling cache data:", err) fmt.Println("[Error] Error marshalling cache data:", err)
return return MusicItem{}
} }
err = os.WriteFile(cacheFile, cacheData, 0644) err = os.WriteFile(cacheFile, cacheData, 0644)
if err != nil { if err != nil {
fmt.Println("[Error] Error writing cache file:", err) fmt.Println("[Error] Error writing cache file:", err)
return return MusicItem{}
} }
fmt.Println("[Info] Music request and caching completed successfully.") fmt.Println("[Info] Music request and caching completed successfully.")
return musicItem
} }
// Helper function to read music data from cache file // Helper function to read music data from cache file
@@ -261,22 +434,3 @@ func readFromCache(filePath string) (MusicItem, bool) {
return musicItem, true 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")
}

View File

@@ -13,4 +13,5 @@ func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\"><link rel=\"icon\" href=\"favicon.ico\"><title>404 Music Lost!</title><style>@import url('https://fonts.googleapis.com/css?family=Montserrat:400,600,700');@import url('https://fonts.googleapis.com/css?family=Catamaran:400,800');.error-container {text-align: center;font-size: 106px;font-family: 'Catamaran', sans-serif;font-weight: 800;margin: 70px 15px;}.error-container>span {display: inline-block;position: relative;}.error-container>span.four {width: 136px;height: 43px;border-radius: 999px;background:linear-gradient(140deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 43%, transparent 44%, transparent 100%),linear-gradient(105deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.06) 41%, rgba(0, 0, 0, 0.07) 76%, transparent 77%, transparent 100%),linear-gradient(to right, #d89ca4, #e27b7e);}.error-container>span.four:before,.error-container>span.four:after {content: '';display: block;position: absolute;border-radius: 999px;}.error-container>span.four:before {width: 43px;height: 156px;left: 60px;bottom: -43px;background:linear-gradient(128deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 40%, transparent 41%, transparent 100%),linear-gradient(116deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 50%, transparent 51%, transparent 100%),linear-gradient(to top, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F);}.error-container>span.four:after {width: 137px;height: 43px;transform: rotate(-49.5deg);left: -18px;bottom: 36px;background: linear-gradient(to right, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F);}.error-container>span.zero {vertical-align: text-top;width: 156px;height: 156px;border-radius: 999px;background: linear-gradient(-45deg, transparent 0%, rgba(0, 0, 0, 0.06) 50%, transparent 51%, transparent 100%),linear-gradient(to top right, #99749D, #99749D, #B895AB, #CC9AA6, #D7969E, #ED8687, #ED8687);overflow: hidden;animation: bgshadow 5s infinite;}.error-container>span.zero:before {content: '';display: block;position: absolute;transform: rotate(45deg);width: 90px;height: 90px;background-color: transparent;left: 0px;bottom: 0px;background:linear-gradient(95deg, transparent 0%, transparent 8%, rgba(0, 0, 0, 0.07) 9%, transparent 50%, transparent 100%),linear-gradient(85deg, transparent 0%, transparent 19%, rgba(0, 0, 0, 0.05) 20%, rgba(0, 0, 0, 0.07) 91%, transparent 92%, transparent 100%);}.error-container>span.zero:after {content: '';display: block;position: absolute;border-radius: 999px;width: 70px;height: 70px;left: 43px;bottom: 43px;background: #FDFAF5;box-shadow: -2px 2px 2px 0px rgba(0, 0, 0, 0.1);}.screen-reader-text {position: absolute;top: -9999em;left: -9999em;}@keyframes bgshadow {0% {box-shadow: inset -160px 160px 0px 5px rgba(0, 0, 0, 0.4);}45% {box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1);}55% {box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1);}100% {box-shadow: inset 160px -160px 0px 5px rgba(0, 0, 0, 0.4);}}* {-webkit-box-sizing: border-box;-moz-box-sizing: border-box;box-sizing: border-box;}body {background-color: #FDFAF5;margin-bottom: 50px;}html,button,input,select,textarea {font-family: 'Montserrat', Helvetica, sans-serif;color: #bbb;}h1 {text-align: center;margin: 30px 15px;}.zoom-area {max-width: 490px;margin: 30px auto 30px;font-size: 19px;text-align: center;}.link-container {text-align: center;}a.more-link {text-transform: uppercase;font-size: 13px;background-color: #de7e85;padding: 10px 15px;border-radius: 0;color: #fff;display: inline-block;margin-right: 5px;margin-bottom: 5px;line-height: 1.5;text-decoration: none;margin-top: 50px;letter-spacing: 1px;}</style></head><body><h1>404 Music Lost!</h1><p class=\"zoom-area\">We couldn't find the content you were looking for.</p><section class=\"error-container\"><span class=\"four\"><span class=\"screen-reader-text\">4</span></span><span class=\"zero\"><span class=\"screen-reader-text\">0</span></span><span class=\"four\"><span class=\"screen-reader-text\">4</span></span></section>") fmt.Fprint(w, "<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\"><link rel=\"icon\" href=\"favicon.ico\"><title>404 Music Lost!</title><style>@import url('https://fonts.googleapis.com/css?family=Montserrat:400,600,700');@import url('https://fonts.googleapis.com/css?family=Catamaran:400,800');.error-container {text-align: center;font-size: 106px;font-family: 'Catamaran', sans-serif;font-weight: 800;margin: 70px 15px;}.error-container>span {display: inline-block;position: relative;}.error-container>span.four {width: 136px;height: 43px;border-radius: 999px;background:linear-gradient(140deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 43%, transparent 44%, transparent 100%),linear-gradient(105deg, transparent 0%, transparent 40%, rgba(0, 0, 0, 0.06) 41%, rgba(0, 0, 0, 0.07) 76%, transparent 77%, transparent 100%),linear-gradient(to right, #d89ca4, #e27b7e);}.error-container>span.four:before,.error-container>span.four:after {content: '';display: block;position: absolute;border-radius: 999px;}.error-container>span.four:before {width: 43px;height: 156px;left: 60px;bottom: -43px;background:linear-gradient(128deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 40%, transparent 41%, transparent 100%),linear-gradient(116deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 50%, transparent 51%, transparent 100%),linear-gradient(to top, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F);}.error-container>span.four:after {width: 137px;height: 43px;transform: rotate(-49.5deg);left: -18px;bottom: 36px;background: linear-gradient(to right, #99749D, #B895AB, #CC9AA6, #D7969E, #E0787F);}.error-container>span.zero {vertical-align: text-top;width: 156px;height: 156px;border-radius: 999px;background: linear-gradient(-45deg, transparent 0%, rgba(0, 0, 0, 0.06) 50%, transparent 51%, transparent 100%),linear-gradient(to top right, #99749D, #99749D, #B895AB, #CC9AA6, #D7969E, #ED8687, #ED8687);overflow: hidden;animation: bgshadow 5s infinite;}.error-container>span.zero:before {content: '';display: block;position: absolute;transform: rotate(45deg);width: 90px;height: 90px;background-color: transparent;left: 0px;bottom: 0px;background:linear-gradient(95deg, transparent 0%, transparent 8%, rgba(0, 0, 0, 0.07) 9%, transparent 50%, transparent 100%),linear-gradient(85deg, transparent 0%, transparent 19%, rgba(0, 0, 0, 0.05) 20%, rgba(0, 0, 0, 0.07) 91%, transparent 92%, transparent 100%);}.error-container>span.zero:after {content: '';display: block;position: absolute;border-radius: 999px;width: 70px;height: 70px;left: 43px;bottom: 43px;background: #FDFAF5;box-shadow: -2px 2px 2px 0px rgba(0, 0, 0, 0.1);}.screen-reader-text {position: absolute;top: -9999em;left: -9999em;}@keyframes bgshadow {0% {box-shadow: inset -160px 160px 0px 5px rgba(0, 0, 0, 0.4);}45% {box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1);}55% {box-shadow: inset 0px 0px 0px 0px rgba(0, 0, 0, 0.1);}100% {box-shadow: inset 160px -160px 0px 5px rgba(0, 0, 0, 0.4);}}* {-webkit-box-sizing: border-box;-moz-box-sizing: border-box;box-sizing: border-box;}body {background-color: #FDFAF5;margin-bottom: 50px;}html,button,input,select,textarea {font-family: 'Montserrat', Helvetica, sans-serif;color: #bbb;}h1 {text-align: center;margin: 30px 15px;}.zoom-area {max-width: 490px;margin: 30px auto 30px;font-size: 19px;text-align: center;}.link-container {text-align: center;}a.more-link {text-transform: uppercase;font-size: 13px;background-color: #de7e85;padding: 10px 15px;border-radius: 0;color: #fff;display: inline-block;margin-right: 5px;margin-bottom: 5px;line-height: 1.5;text-decoration: none;margin-top: 50px;letter-spacing: 1px;}</style></head><body><h1>404 Music Lost!</h1><p class=\"zoom-area\">We couldn't find the content you were looking for.</p><section class=\"error-container\"><span class=\"four\"><span class=\"screen-reader-text\">4</span></span><span class=\"zero\"><span class=\"screen-reader-text\">0</span></span><span class=\"four\"><span class=\"screen-reader-text\">4</span></span></section>")
fmt.Fprintf(w, "<div class=\"link-container\"><a href=\"%s\" class=\"more-link\">Go Home</a></div></body>", home_url) fmt.Fprintf(w, "<div class=\"link-container\"><a href=\"%s\" class=\"more-link\">Go Home</a></div></body>", home_url)
fmt.Printf("[Web Access] Return 404 Not Found\n")
} }

View File

@@ -25,6 +25,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
defaultIndexPage(w) defaultIndexPage(w)
} else { } else {
http.ServeFile(w, r, indexPath) http.ServeFile(w, r, indexPath)
fmt.Printf("[Web Access] Return custom index pages\n")
} }
} }
@@ -372,4 +373,5 @@ func defaultIndexPage(w http.ResponseWriter) {
// Hide stream_pcm response // Hide stream_pcm response
fmt.Fprintf(w, "hideStreamPcmBtn.addEventListener('click', function () {streamPcm.style.display = 'none';showStreamPcmBtn.style.display = 'block';hideStreamPcmBtn.style.display = 'none';});") fmt.Fprintf(w, "hideStreamPcmBtn.addEventListener('click', function () {streamPcm.style.display = 'none';showStreamPcmBtn.style.display = 'block';hideStreamPcmBtn.style.display = 'none';});")
fmt.Fprintf(w, "</script></body></html>") fmt.Fprintf(w, "</script></body></html>")
fmt.Printf("[Web Access] Return default index pages\n")
} }

View File

@@ -3,6 +3,7 @@
"title": "", "title": "",
"artist": "", "artist": "",
"audio_url": "", "audio_url": "",
"audio_full_url": "",
"m3u8_url": "", "m3u8_url": "",
"lyric_url": "", "lyric_url": "",
"cover_url": "", "cover_url": "",
@@ -12,6 +13,7 @@
"title": "", "title": "",
"artist": "", "artist": "",
"audio_url": "", "audio_url": "",
"audio_full_url": "",
"m3u8_url": "", "m3u8_url": "",
"lyric_url": "", "lyric_url": "",
"cover_url": "", "cover_url": "",

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -26,20 +27,20 @@ type YuafengAPIFreeResponse struct {
// 枫雨API response handler. // 枫雨API response handler.
func YuafengAPIResponseHandler(sources, song, singer string) MusicItem { func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
fmt.Printf("[Info] Fetching music data from 枫林 free API for %s by %s\n", song, singer) fmt.Printf("[Info] Fetching music data from 枫林 free API for %s by %s\n", song, singer)
var url string var APIurl string
switch sources { switch sources {
case "kuwo": case "kuwo":
url = "https://api.yuafeng.cn/API/ly/kwmusic.php" APIurl = "https://api.yuafeng.cn/API/ly/kwmusic.php"
case "netease": case "netease":
url = "https://api.yuafeng.cn/API/ly/wymusic.php" APIurl = "https://api.yuafeng.cn/API/ly/wymusic.php"
case "migu": case "migu":
url = "https://api.yuafeng.cn/API/ly/mgmusic.php" APIurl = "https://api.yuafeng.cn/API/ly/mgmusic.php"
case "baidu": case "baidu":
url = "https://api.yuafeng.cn/API/ly/bdmusic.php" APIurl = "https://api.yuafeng.cn/API/ly/bdmusic.php"
default: default:
return MusicItem{} return MusicItem{}
} }
resp, err := http.Get(url + "?msg=" + song + "&n=1") resp, err := http.Get(APIurl + "?msg=" + song + "&n=1")
if err != nil { if err != nil {
fmt.Println("[Error] Error fetching the data from Yuafeng free API:", err) fmt.Println("[Error] Error fetching the data from Yuafeng free API:", err)
return MusicItem{} return MusicItem{}
@@ -149,15 +150,14 @@ func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
fmt.Println("[Error] Error creating m3u8 playlist:", err) fmt.Println("[Error] Error creating m3u8 playlist:", err)
} }
websiteURL := os.Getenv("WEBSITE_URL")
return MusicItem{ return MusicItem{
Title: response.Data.Song, Title: response.Data.Song,
Artist: response.Data.Singer, Artist: response.Data.Singer,
CoverURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/cover" + ext, CoverURL: "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) + "/cover" + ext,
LyricURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/lyric.lrc", LyricURL: "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) + "/lyric.lrc",
AudioFullURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music_full" + musicExt, AudioFullURL: "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) + "/music_full" + musicExt,
AudioURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.mp3", AudioURL: "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) + "/music.mp3",
M3U8URL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.m3u8", M3U8URL: "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) + "/music.m3u8",
Duration: duration, Duration: duration,
} }
} }