Compare commits

11 Commits

Author SHA1 Message Date
71b65e3707 add bug issue 2025-12-06 22:00:49 +08:00
98365d7859 add issue template 2025-12-06 19:49:06 +08:00
161cbdfebf add issue template 2025-12-06 19:48:28 +08:00
415172733f add funding 2025-12-03 17:12:20 +08:00
0e3e655939 add funding 2025-12-03 17:09:20 +08:00
6e1fd62247 Version comparison modification 2025-12-02 22:04:59 +08:00
5d54f8eb12 Resolve the issue of slow initial playback. 2025-12-02 21:56:51 +08:00
2cd8d0dc37 Flow enhancement 2025-12-02 21:41:21 +08:00
d9abb0b18b Flow optimization 2025-12-02 20:27:05 +08:00
0c097d63a6 Fixed issues such as slow/unable to play music through stream_pcm. 2025-12-02 19:46:41 +08:00
a8028bdc28 permissions 2025-12-02 18:53:05 +08:00
11 changed files with 252 additions and 232 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: [MoeCinnamo]
custom: ["https://afdian.com/a/MoeCinnamo"]

60
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: 🐞 Report error
description: Report an error (bug), please first check the FAQ and search the Issue list for the issue you want to raise.
title: "[Bug] "
body:
- type: checkboxes
id: check-answer
attributes:
label: Solution check
description: Please ensure that you have completed all of the following operations.
options:
- label: I have searched [Issues](https://github.com/OmniX-Space/MeowBox-Core/issues).However, no similar issues were found.
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what is expected to happen
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: A clear and concise description of what actually happened.
validations:
required: true
- type: input
id: version
attributes:
label: MeowBox-Core version
description: What version of MeowBox-Core are you using?
placeholder: e.g. v0.0.2
validations:
required: true
- type: input
id: last-known-working-version
attributes:
label: The final normal version
description: If so, please fill in the final normal version here.
placeholder: e.g. v0.0.1
- type: input
id: operating-system-version
attributes:
label: Operating system version
description: |
What version of operating system are you using?
On macOS, click on "Apple Menu > About This Machine";
On Linux, execute the `lsc_release` or `uname - a` command;
On Windows, click the Start button > Settings > System > About.
placeholder: "e.g. macOS 11.2.3, Windows 10 20H2, Debian 12.1.0"
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional information
description: If your issue needs further explanation or you are facing a difficult-to-reproduce issue, please add more information here. (You can directly drag and drop images or videos into the text box)

36
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: ✨ Function request
description: To come up with an idea for this project, please first review the frequently asked questions and search the Issue list to see if there are any issues you want to raise.
title: "[Feature] "
body:
- type: checkboxes
id: check-answer
attributes:
label: Solution check
description: Please ensure that you have completed all of the following operations.
options:
- label: I have searched [Issues](https://github.com/OmniX-Space/MeowBox-Core/issues).However, no similar issues were found.
required: true
- type: textarea
id: problem-description
attributes:
label: Problem description
description: Please add a clear and concise description of the problem you want to solve.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Describe the solution you want
description: Briefly and clearly describe what you are about to happen
validations:
required: true
- type: textarea
id: alternatives-considered
attributes:
label: Describe the alternative solutions you have considered
description: A concise and clear description of all alternative solutions or features you have considered
- type: textarea
id: additional-information
attributes:
label: Additional information
description: If your question requires further explanation or if you would like to express other content, please add more information here. (Simply drag the image/video to the editing box to add it)

View File

@@ -16,11 +16,16 @@ on:
jobs: jobs:
build: build:
runs-on: self-hosted runs-on: self-hosted
permissions:
contents: write
actions: write
pull-requests: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: v0.0.2-dev ref: v0.0.2-dev
fetch-depth: 0
- name: Test code - name: Test code
run: | run: |
echo "Testing code" echo "Testing code"
@@ -70,6 +75,13 @@ jobs:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -trimpath -o MeowEmbeddedMusicServer-Darwin-amd64 . CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -trimpath -o MeowEmbeddedMusicServer-Darwin-amd64 .
echo "Build darwin arm64 binary" echo "Build darwin arm64 binary"
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -trimpath -o MeowEmbeddedMusicServer-Darwin-arm64 . CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -trimpath -o MeowEmbeddedMusicServer-Darwin-arm64 .
- name: Get latest tag
id: get_latest_tag
run: |
git fetch --tags
LATEST_TAG=$(git tag --sort=-refname | head -n 1)
echo "Latest tag found: $LATEST_TAG"
echo "previous_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
- name: Upload binaries - name: Upload binaries
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
@@ -90,7 +102,7 @@ jobs:
- macOS amd64 - macOS amd64
- macOS arm64 - macOS arm64
**Full Changelog**: https://github.com/OmniX-Space/MeowBox-Core/compare/v0.0.1...${{ github.sha }} **Full Changelog**: https://github.com/OmniX-Space/MeowBox-Core/compare/${{ steps.get_latest_tag.outputs.previous_tag }}...${{ github.sha }}
files: | files: |
MeowEmbeddedMusicServer-Linux-i386 MeowEmbeddedMusicServer-Linux-i386
MeowEmbeddedMusicServer-Linux-amd64 MeowEmbeddedMusicServer-Linux-amd64
@@ -103,6 +115,6 @@ jobs:
prerelease: ${{ github.event.inputs.build_type == 'pre' }} prerelease: ${{ github.event.inputs.build_type == 'pre' }}
make_latest: ${{ github.event.inputs.build_type != 'pre' }} make_latest: ${{ github.event.inputs.build_type != 'pre' }}
env: env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.CUSTOM_GITHUB_TOKEN }} # GITHUB_TOKEN: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
continue-on-error: true continue-on-error: true

28
api.go
View File

@@ -165,13 +165,17 @@ 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 {
encodedSong := url.QueryEscape(song)
encodedSinger := url.QueryEscape(singer)
streamURL := scheme + "://" + r.Host + "/stream_live?song=" + encodedSong + "&singer=" + encodedSinger
fmt.Println("[Info] Updating music item cache from API request.") fmt.Println("[Info] Updating music item cache from API request.")
musicItem = requestAndCacheMusic(song, singer) musicItem = requestAndCacheMusic(song, singer)
fmt.Println("[Info] Music item cache updated.") fmt.Println("[Info] Music item cache updated.")
musicItem.FromCache = false musicItem.FromCache = false
musicItem.AudioURL = scheme + "://" + r.Host + musicItem.AudioURL musicItem.AudioURL = streamURL
musicItem.AudioFullURL = scheme + "://" + r.Host + musicItem.AudioFullURL musicItem.AudioFullURL = streamURL
musicItem.M3U8URL = scheme + "://" + r.Host + musicItem.M3U8URL musicItem.M3U8URL = scheme + "://" + r.Host + musicItem.M3U8URL
musicItem.LyricURL = scheme + "://" + r.Host + musicItem.LyricURL musicItem.LyricURL = scheme + "://" + r.Host + musicItem.LyricURL
musicItem.CoverURL = scheme + "://" + r.Host + musicItem.CoverURL musicItem.CoverURL = scheme + "://" + r.Host + musicItem.CoverURL
@@ -188,7 +192,9 @@ func apiHandler(w http.ResponseWriter, r *http.Request) {
musicItem.IP = ip musicItem.IP = ip
} }
json.NewEncoder(w).Encode(musicItem) encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
encoder.Encode(musicItem)
} }
// streamLiveHandler 实时流式转码接口 - 边下载边播放,无需等待! // streamLiveHandler 实时流式转码接口 - 边下载边播放,无需等待!
@@ -196,18 +202,18 @@ func streamLiveHandler(w http.ResponseWriter, r *http.Request) {
// 设置 CORS 和音频相关头 // 设置 CORS 和音频相关头
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Accept-Ranges", "bytes")
queryParams := r.URL.Query() queryParams := r.URL.Query()
song := queryParams.Get("song") song := queryParams.Get("song")
singer := queryParams.Get("singer") singer := queryParams.Get("singer")
fmt.Printf("[Stream Live] Request: song=%s, singer=%s\n", song, singer) fmt.Printf("[Stream Live] Request: song=%s, singer=%s\n", song, singer)
if song == "" { if song == "" {
http.Error(w, "Missing song parameter", http.StatusBadRequest) http.Error(w, "Missing song parameter", http.StatusBadRequest)
return return
} }
// 1. 检查缓存是否存在 // 1. 检查缓存是否存在
dirName := fmt.Sprintf("./files/cache/music/%s-%s", singer, song) dirName := fmt.Sprintf("./files/cache/music/%s-%s", singer, song)
cachedFile := filepath.Join(dirName, "music.mp3") cachedFile := filepath.Join(dirName, "music.mp3")
@@ -218,19 +224,19 @@ func streamLiveHandler(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, cachedFile) http.ServeFile(w, r, cachedFile)
return return
} }
// 2. 缓存不存在获取远程URL并实时流式转码 // 2. 缓存不存在获取远程URL并实时流式转码
fmt.Printf("[Stream Live] Cache miss, fetching from API...\n") fmt.Printf("[Stream Live] Cache miss, fetching from API...\n")
// 调用枫雨API获取远程音乐URL不下载只获取URL // 调用枫雨API获取远程音乐URL不下载只获取URL
remoteURL := getRemoteMusicURLOnly(song, singer) remoteURL := getRemoteMusicURLOnly(song, singer)
if remoteURL == "" { if remoteURL == "" {
http.Error(w, "Failed to get remote music URL", http.StatusNotFound) http.Error(w, "Failed to get remote music URL", http.StatusNotFound)
return return
} }
fmt.Printf("[Stream Live] Starting live stream from: %s\n", remoteURL) fmt.Printf("[Stream Live] Starting live stream from: %s\n", remoteURL)
// 4. 实时流式转码 // 4. 实时流式转码
if err := streamConvertToWriter(remoteURL, w); err != nil { if err := streamConvertToWriter(remoteURL, w); err != nil {
fmt.Printf("[Stream Live] Error: %v\n", err) fmt.Printf("[Stream Live] Error: %v\n", err)

202
file.go
View File

@@ -1,14 +1,12 @@
package main package main
import ( import (
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// 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
@@ -54,10 +52,17 @@ func GetFileContent(filePath string) ([]byte, error) {
// fileHandler function: Handle file requests // fileHandler function: Handle file requests
func fileHandler(w http.ResponseWriter, r *http.Request) { func fileHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "MeowMusicEmbeddedServer") w.Header().Set("Server", "MeowMusicEmbeddedServer")
// Obtain the path of the request
filePath := r.URL.Path filePath := r.URL.Path
// Check if the request path starts with "/url/" // 提前URL解码
decodedPath, decodeErr := url.QueryUnescape(filePath)
if decodeErr == nil {
// 兼容历史数据:将+替换为空格(仅当解码成功时)
decodedPath = strings.ReplaceAll(decodedPath, "+", " ")
filePath = decodedPath // 后续统一使用解码后路径
}
// 处理 /url/ 远程请求(保持不变)
if strings.HasPrefix(filePath, "/url/") { if strings.HasPrefix(filePath, "/url/") {
// Extract the URL after "/url/" // Extract the URL after "/url/"
urlPath := filePath[len("/url/"):] urlPath := filePath[len("/url/"):]
@@ -102,161 +107,54 @@ func fileHandler(w http.ResponseWriter, r *http.Request) {
NotFoundHandler(w, r) NotFoundHandler(w, r)
return return
} }
// Set appropriate Content-Type based on file extension setContentType(w, decodedURL)
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 // Write file content to response
w.Write(fileContent) w.Write(fileContent)
return return
} }
// Construct the complete file path // 统一使用解码后路径
fullFilePath := filepath.Join("./files", filePath) fullPath := filepath.Join("./files", filePath)
fileContent, err := GetFileContent(fullPath)
// Try replacing '+' with ' ' and check if the file exists // 特殊处理空music.mp3
tempFilePath := strings.ReplaceAll(fullFilePath, "+", " ") isEmptyMusic := (err == nil && len(fileContent) == 0 && strings.HasSuffix(filePath, "/music.mp3"))
if _, err := os.Stat(tempFilePath); err == nil { if err != nil || isEmptyMusic {
fullFilePath = tempFilePath // 没有/空的music.mp3文件直接返回404
NotFoundHandler(w, r)
return
} }
// Get file content // 避免重复Content-Type设置
fileContent, err := GetFileContent(fullFilePath) setContentType(w, filePath)
// 检查是否为空文件(特别是 music.mp3如果是空的也视为不存在需要等待转码
if err == nil && len(fileContent) == 0 && strings.HasSuffix(filePath, "/music.mp3") {
err = fmt.Errorf("file is empty")
}
if err != nil {
// If file not found, try replacing ' ' with '+' and check again
tempFilePath = strings.ReplaceAll(fullFilePath, " ", "+")
fileContent, err = GetFileContent(tempFilePath)
// 同样检查带 + 的路径是否为空
if err == nil && len(fileContent) == 0 && strings.HasSuffix(filePath, "/music.mp3") {
err = fmt.Errorf("file is empty")
}
if err != nil {
// 特殊处理:如果请求的是缓存中的文件,等待后台处理完成
fmt.Printf("[Web Access] File not found, checking path prefix: %s\n", filePath)
if strings.HasPrefix(filePath, "/cache/music/") {
// music.mp3 等待最多 60 秒,歌词等待最多 10 秒
maxWait := 10
if strings.HasSuffix(filePath, "/music.mp3") {
maxWait = 60
}
// URL 解码路径(处理中文文件名)
decodedPath, _ := url.QueryUnescape(filePath)
decodedPath = strings.ReplaceAll(decodedPath, "+", " ") // + 转空格
decodedFullPath := filepath.Join("./files", decodedPath)
fmt.Printf("[Web Access] Waiting for file: %s (max %d seconds)\n", decodedFullPath, maxWait)
for i := 0; i < maxWait; i++ {
time.Sleep(1 * time.Second)
// 检查 URL 解码后的路径
if _, err := os.Stat(decodedFullPath); err == nil {
fmt.Printf("[Web Access] File ready after %d seconds: %s\n", i+1, decodedFullPath)
http.ServeFile(w, r, decodedFullPath)
return
}
// 检查原始路径
if _, err := os.Stat(fullFilePath); err == nil {
http.ServeFile(w, r, fullFilePath)
return
}
// 检查带 + 的路径
tempPath := strings.ReplaceAll(fullFilePath, " ", "+")
if _, err := os.Stat(tempPath); err == nil {
http.ServeFile(w, r, tempPath)
return
}
}
}
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) w.Write(fileContent)
} }
func setContentType(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path))
contentTypes := map[string]string{
".mp3": "audio/mpeg",
".wav": "audio/wav",
".flac": "audio/flac",
".aac": "audio/aac",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".amr": "audio/amr",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".webp": "image/webp",
".txt": "text/plain",
".lrc": "text/plain",
".mrc": "text/plain",
".json": "application/json",
}
if ct, ok := contentTypes[ext]; ok {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
}

View File

View File

@@ -1 +1,6 @@
{} {
"favorite": {
"name": "我喜欢",
"songs": []
}
}

View File

@@ -85,7 +85,7 @@ func readFromCache(path string) (MusicItem, bool) {
if err != nil || !info.IsDir() { if err != nil || !info.IsDir() {
return MusicItem{}, false return MusicItem{}, false
} }
dirName := filepath.Base(path) dirName := filepath.Base(path)
parts := strings.SplitN(dirName, "-", 2) parts := strings.SplitN(dirName, "-", 2)
var artist, title string var artist, title string
@@ -95,7 +95,7 @@ func readFromCache(path string) (MusicItem, bool) {
} else { } else {
title = dirName title = dirName
} }
return getLocalMusicItem(title, artist), true return getLocalMusicItem(title, artist), true
} }
@@ -115,10 +115,10 @@ func requestAndCacheMusic(song, singer string) MusicItem {
// 直接从远程URL流式转码边下载边转码超快 // 直接从远程URL流式转码边下载边转码超快
func streamConvertAudio(inputURL, outputFile string) error { func streamConvertAudio(inputURL, outputFile string) error {
fmt.Printf("[Info] Stream converting from URL (fast mode)\n") fmt.Printf("[Info] Stream converting from URL (fast mode)\n")
// 先写入临时文件,完成后再重命名(避免读取到不完整的文件) // 先写入临时文件,完成后再重命名(避免读取到不完整的文件)
tempFile := outputFile + ".tmp" tempFile := outputFile + ".tmp"
// ffmpeg 直接读取远程 URL 并转码 // ffmpeg 直接读取远程 URL 并转码
// -t 600: 只下载前10分钟减少80%下载量! // -t 600: 只下载前10分钟减少80%下载量!
// 移除 reconnect 参数,避免兼容性问题 // 移除 reconnect 参数,避免兼容性问题
@@ -130,14 +130,14 @@ func streamConvertAudio(inputURL, outputFile string) error {
"-ac", "1", "-ar", "24000", "-b:a", "32k", "-q:a", "9", "-ac", "1", "-ar", "24000", "-b:a", "32k", "-q:a", "9",
"-bufsize", "64k", "-bufsize", "64k",
tempFile) tempFile)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
fmt.Printf("[Error] Stream convert failed: %v\n", err) fmt.Printf("[Error] Stream convert failed: %v\n", err)
os.Remove(tempFile) // 清理临时文件 os.Remove(tempFile) // 清理临时文件
return err return err
} }
// 检查生成的文件大小 // 检查生成的文件大小
fileInfo, err := os.Stat(tempFile) fileInfo, err := os.Stat(tempFile)
if err != nil || fileInfo.Size() < 1024 { if err != nil || fileInfo.Size() < 1024 {
@@ -152,7 +152,7 @@ func streamConvertAudio(inputURL, outputFile string) error {
fmt.Printf("[Error] Failed to rename temp file: %v\n", err) fmt.Printf("[Error] Failed to rename temp file: %v\n", err)
return err return err
} }
fmt.Printf("[Success] Stream convert completed: %s\n", outputFile) fmt.Printf("[Success] Stream convert completed: %s\n", outputFile)
return nil return nil
} }
@@ -160,30 +160,31 @@ func streamConvertAudio(inputURL, outputFile string) error {
// 实时流式转码到 HTTP Writer边下载边播放 // 实时流式转码到 HTTP Writer边下载边播放
func streamConvertToWriter(inputURL string, w http.ResponseWriter) error { func streamConvertToWriter(inputURL string, w http.ResponseWriter) error {
fmt.Printf("[Info] Live streaming from URL: %s\n", inputURL) fmt.Printf("[Info] Live streaming from URL: %s\n", inputURL)
// ffmpeg 边下载边转码,输出到 stdout // ffmpeg 边下载边转码,输出到 stdout
cmd := exec.Command("ffmpeg", cmd := exec.Command("ffmpeg",
"-i", inputURL, "-i", inputURL,
"-threads", "0", "-threads", "0",
"-ac", "1", "-ar", "24000", "-b:a", "32k", "-q:a", "9", "-ac", "1", "-ar", "24000", "-b:a", "32k", "-q:a", "9",
"-f", "mp3", "-f", "mp3",
"-map_metadata", "-1",
"pipe:1") // 输出到 stdout "pipe:1") // 输出到 stdout
// 获取 stdout pipe // 获取 stdout pipe
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return fmt.Errorf("failed to get stdout pipe: %v", err) return fmt.Errorf("failed to get stdout pipe: %v", err)
} }
// 启动 ffmpeg // 启动 ffmpeg
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %v", err) return fmt.Errorf("failed to start ffmpeg: %v", err)
} }
// 设置响应头 // 设置响应头
w.Header().Set("Content-Type", "audio/mpeg") w.Header().Set("Content-Type", "audio/mpeg")
// 移除 Transfer-Encoding: chunked让 Go 自动处理 // 移除 Transfer-Encoding: chunked让 Go 自动处理
// 边读边写到 HTTP response // 边读边写到 HTTP response
buf := make([]byte, 8192) buf := make([]byte, 8192)
for { for {
@@ -198,7 +199,7 @@ func streamConvertToWriter(inputURL string, w http.ResponseWriter) error {
break break
} }
} }
cmd.Wait() cmd.Wait()
fmt.Printf("[Success] Live streaming completed\n") fmt.Printf("[Success] Live streaming completed\n")
return nil return nil

View File

@@ -11,7 +11,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "MeowMusicEmbeddedServer") w.Header().Set("Server", "MeowMusicEmbeddedServer")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Printf("[Web Access] Handling request for %s\n", r.URL.Path) fmt.Printf("[Web Access] Handling request for %s\n", r.URL.Path)
// Serve full music app for both / and /app // Serve full music app for both / and /app
if r.URL.Path == "/" || r.URL.Path == "/app" { if r.URL.Path == "/" || r.URL.Path == "/app" {
appPath := filepath.Join("theme", "full-app.html") appPath := filepath.Join("theme", "full-app.html")
@@ -21,7 +21,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// Test version available at /test // Test version available at /test
if r.URL.Path == "/test" { if r.URL.Path == "/test" {
testPath := filepath.Join("theme", "test-app.html") testPath := filepath.Join("theme", "test-app.html")
@@ -31,7 +31,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// Access classic interface via /classic // Access classic interface via /classic
if r.URL.Path == "/classic" { if r.URL.Path == "/classic" {
indexPath := filepath.Join("theme", "index.html") indexPath := filepath.Join("theme", "index.html")
@@ -43,7 +43,7 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
defaultIndexPage(w) defaultIndexPage(w)
return return
} }
if r.URL.Path != "/" { if r.URL.Path != "/" {
fileHandler(w, r) fileHandler(w, r)
return return

View File

@@ -29,14 +29,14 @@ type YuafengAPIFreeResponse struct {
// 枫雨API response handler with multiple API fallback // 枫雨API response handler with multiple API fallback
func YuafengAPIResponseHandler(sources, song, singer string) MusicItem { func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
fmt.Printf("[Info] Fetching music data for %s by %s\n", song, singer) fmt.Printf("[Info] Fetching music data for %s by %s\n", song, singer)
// API hosts to try in order // API hosts to try in order
apiHosts := []string{ apiHosts := []string{
"https://api.yuafeng.cn", "https://api.yuafeng.cn",
"https://api-v2.yuafeng.cn", "https://api-v2.yuafeng.cn",
"https://api.yaohud.cn", "https://api.yaohud.cn",
} }
var pathSuffix string var pathSuffix string
switch sources { switch sources {
case "kuwo": case "kuwo":
@@ -50,9 +50,9 @@ func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
default: default:
return MusicItem{} return MusicItem{}
} }
var fallbackItem MusicItem // 保存第一个有音乐但没歌词的结果 var fallbackItem MusicItem // 保存第一个有音乐但没歌词的结果
// Try each API host - 尝试所有API直到找到歌词 // Try each API host - 尝试所有API直到找到歌词
for i, host := range apiHosts { for i, host := range apiHosts {
fmt.Printf("[Info] Trying API %d/%d: %s\n", i+1, len(apiHosts), host) fmt.Printf("[Info] Trying API %d/%d: %s\n", i+1, len(apiHosts), host)
@@ -74,13 +74,13 @@ func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
fmt.Printf("[Warning] × API %s failed, trying next...\n", host) fmt.Printf("[Warning] × API %s failed, trying next...\n", host)
} }
} }
// 所有API都试完了 // 所有API都试完了
if fallbackItem.Title != "" { if fallbackItem.Title != "" {
fmt.Println("[Info] ▶ All 3 APIs tried - no lyrics found, returning music without lyrics") fmt.Println("[Info] ▶ All 3 APIs tried - no lyrics found, returning music without lyrics")
return fallbackItem return fallbackItem
} }
fmt.Println("[Error] ✗ All 3 APIs failed completely") fmt.Println("[Error] ✗ All 3 APIs failed completely")
return MusicItem{} return MusicItem{}
} }
@@ -98,17 +98,17 @@ func tryFetchFromAPI(APIurl, song, singer string) MusicItem {
fmt.Println("[Error] Error reading the response body:", err) fmt.Println("[Error] Error reading the response body:", err)
return MusicItem{} return MusicItem{}
} }
// Check if response is HTML (starts with < character) // Check if response is HTML (starts with < character)
bodyStr := string(body) bodyStr := string(body)
if len(bodyStr) > 0 && bodyStr[0] == '<' { if len(bodyStr) > 0 && bodyStr[0] == '<' {
fmt.Println("[Warning] API returned HTML instead of JSON") fmt.Println("[Warning] API returned HTML instead of JSON")
fmt.Printf("[Debug] Saving HTML response to debug.html for inspection\n") fmt.Printf("[Debug] Saving HTML response to debug.html for inspection\n")
// Save HTML to file for debugging // Save HTML to file for debugging
os.WriteFile("debug_api_response.html", body, 0644) os.WriteFile("debug_api_response.html", body, 0644)
fmt.Println("[Info] HTML response saved to debug_api_response.html") fmt.Println("[Info] HTML response saved to debug_api_response.html")
// Try to extract JSON from HTML if embedded // Try to extract JSON from HTML if embedded
// Look for common patterns where JSON might be embedded // Look for common patterns where JSON might be embedded
if strings.Contains(bodyStr, `"song"`) && strings.Contains(bodyStr, `"singer"`) { if strings.Contains(bodyStr, `"song"`) && strings.Contains(bodyStr, `"singer"`) {
@@ -127,11 +127,11 @@ func tryFetchFromAPI(APIurl, song, singer string) MusicItem {
} }
} }
} }
fmt.Println("[Error] Cannot parse HTML response - API may be unavailable") fmt.Println("[Error] Cannot parse HTML response - API may be unavailable")
return MusicItem{} return MusicItem{}
} }
parseSuccess: parseSuccess:
var response YuafengAPIFreeResponse var response YuafengAPIFreeResponse
err = json.Unmarshal(body, &response) err = json.Unmarshal(body, &response)
@@ -155,17 +155,17 @@ parseSuccess:
// 获取封面扩展名 // 获取封面扩展名
ext := filepath.Ext(response.Data.Cover) ext := filepath.Ext(response.Data.Cover)
// 保存远程 URL 到文件,供 file.go 流式转码使用 // 保存远程 URL 到文件,供 file.go 流式转码使用
remoteURLFile := filepath.Join(dirName, "remote_url.txt") remoteURLFile := filepath.Join(dirName, "remote_url.txt")
os.WriteFile(remoteURLFile, []byte(response.Data.Music), 0644) os.WriteFile(remoteURLFile, []byte(response.Data.Music), 0644)
// ========== 关键优化:先返回,后台异步处理 ========== // ========== 关键优化:先返回,后台异步处理 ==========
// 把下载、转码等耗时操作放到 goroutine 异步执行 // 把下载、转码等耗时操作放到 goroutine 异步执行
go func() { go func() {
fmt.Printf("[Async] Starting background processing for: %s - %s\n", response.Data.Singer, response.Data.Song) fmt.Printf("[Async] Starting background processing for: %s - %s\n", response.Data.Singer, response.Data.Song)
var wg sync.WaitGroup var wg sync.WaitGroup
// ========== 1. 歌词处理 ========== // ========== 1. 歌词处理 ==========
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -199,14 +199,14 @@ parseSuccess:
downloadFile(filepath.Join(dirName, "lyric.lrc"), lyricData) downloadFile(filepath.Join(dirName, "lyric.lrc"), lyricData)
} }
}() }()
// ========== 2. 封面处理 ========== // ========== 2. 封面处理 ==========
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
downloadFile(filepath.Join(dirName, "cover"+ext), response.Data.Cover) downloadFile(filepath.Join(dirName, "cover"+ext), response.Data.Cover)
}() }()
// ========== 3. 音频转码 ========== // ========== 3. 音频转码 ==========
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -216,7 +216,7 @@ parseSuccess:
fmt.Println("[Async Warning] Cannot identify music format, using default .mp3:", err) fmt.Println("[Async Warning] Cannot identify music format, using default .mp3:", err)
musicExt = ".mp3" musicExt = ".mp3"
} }
outputMp3 := filepath.Join(dirName, "music.mp3") outputMp3 := filepath.Join(dirName, "music.mp3")
err = streamConvertAudio(response.Data.Music, outputMp3) err = streamConvertAudio(response.Data.Music, outputMp3)
if err != nil { if err != nil {
@@ -228,7 +228,7 @@ parseSuccess:
} }
} }
}() }()
wg.Wait() // 等待所有任务完成 wg.Wait() // 等待所有任务完成
fmt.Printf("[Async] Background processing completed for: %s - %s\n", response.Data.Singer, response.Data.Song) fmt.Printf("[Async] Background processing completed for: %s - %s\n", response.Data.Singer, response.Data.Song)
}() }()
@@ -236,7 +236,7 @@ parseSuccess:
// ========== 立即返回 JSON使用标准 .mp3 URL ========== // ========== 立即返回 JSON使用标准 .mp3 URL ==========
// 注意:返回标准的 .mp3 URLfile.go 会在文件不存在时自动触发流式转码 // 注意:返回标准的 .mp3 URLfile.go 会在文件不存在时自动触发流式转码
basePath := "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song) basePath := "/cache/music/" + url.QueryEscape(response.Data.Singer+"-"+response.Data.Song)
return MusicItem{ return MusicItem{
Title: response.Data.Song, Title: response.Data.Song,
Artist: response.Data.Singer, Artist: response.Data.Singer,
@@ -274,55 +274,55 @@ type YaohuLyricResponse struct {
func fetchLyricFromYaohu(songName, artistName, dirPath string) bool { func fetchLyricFromYaohu(songName, artistName, dirPath string) bool {
apiKey := "bXO9eq1pomwR1cyVhzX" apiKey := "bXO9eq1pomwR1cyVhzX"
apiURL := "https://api.yaohud.cn/api/music/qq" apiURL := "https://api.yaohud.cn/api/music/qq"
// 构建请求URL - QQ音乐VIP接口 // 构建请求URL - QQ音乐VIP接口
requestURL := fmt.Sprintf("%s?key=%s&msg=%s&n=1&size=hq", requestURL := fmt.Sprintf("%s?key=%s&msg=%s&n=1&size=hq",
apiURL, apiURL,
apiKey, apiKey,
url.QueryEscape(songName)) url.QueryEscape(songName))
fmt.Printf("[Info] 🎵 Trying to fetch lyric from Yaohu QQ Music VIP API for: %s - %s\n", artistName, songName) fmt.Printf("[Info] 🎵 Trying to fetch lyric from Yaohu QQ Music VIP API for: %s - %s\n", artistName, songName)
// 创建带超时的HTTP客户端 // 创建带超时的HTTP客户端
client := &http.Client{ client := &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
} }
resp, err := client.Get(requestURL) resp, err := client.Get(requestURL)
if err != nil { if err != nil {
fmt.Printf("[Error] Yaohu QQ Music API request failed: %v\n", err) fmt.Printf("[Error] Yaohu QQ Music API request failed: %v\n", err)
return false return false
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
fmt.Printf("[Error] Failed to read API response: %v\n", err) fmt.Printf("[Error] Failed to read API response: %v\n", err)
return false return false
} }
var qqResp YaohuQQMusicResponse var qqResp YaohuQQMusicResponse
err = json.Unmarshal(body, &qqResp) err = json.Unmarshal(body, &qqResp)
if err != nil { if err != nil {
fmt.Printf("[Error] Failed to parse API response: %v\n", err) fmt.Printf("[Error] Failed to parse API response: %v\n", err)
return false return false
} }
// 检查响应状态 // 检查响应状态
if qqResp.Code != 200 { if qqResp.Code != 200 {
fmt.Printf("[Warning] API returned error (code: %d, msg: %s)\n", qqResp.Code, qqResp.Msg) fmt.Printf("[Warning] API returned error (code: %d, msg: %s)\n", qqResp.Code, qqResp.Msg)
return false return false
} }
// 检查viplrc URL是否存在 // 检查viplrc URL是否存在
if qqResp.Data.Viplrc == "" { if qqResp.Data.Viplrc == "" {
fmt.Printf("[Warning] No lyric URL available for: %s\n", songName) fmt.Printf("[Warning] No lyric URL available for: %s\n", songName)
return false return false
} }
fmt.Printf("[Info] 🔍 Found song: %s - %s\n", qqResp.Data.Songname, qqResp.Data.Name) fmt.Printf("[Info] 🔍 Found song: %s - %s\n", qqResp.Data.Songname, qqResp.Data.Name)
fmt.Printf("[Info] 📝 Fetching lyric from: %s\n", qqResp.Data.Viplrc) fmt.Printf("[Info] 📝 Fetching lyric from: %s\n", qqResp.Data.Viplrc)
// Step 2: 获取实际歌词内容 // Step 2: 获取实际歌词内容
resp2, err := client.Get(qqResp.Data.Viplrc) resp2, err := client.Get(qqResp.Data.Viplrc)
if err != nil { if err != nil {
@@ -330,22 +330,22 @@ func fetchLyricFromYaohu(songName, artistName, dirPath string) bool {
return false return false
} }
defer resp2.Body.Close() defer resp2.Body.Close()
body2, err := io.ReadAll(resp2.Body) body2, err := io.ReadAll(resp2.Body)
if err != nil { if err != nil {
fmt.Printf("[Error] Failed to read lyric response: %v\n", err) fmt.Printf("[Error] Failed to read lyric response: %v\n", err)
return false return false
} }
// viplrc URL直接返回LRC文本不是JSON // viplrc URL直接返回LRC文本不是JSON
lyricText := string(body2) lyricText := string(body2)
// 检查歌词内容 // 检查歌词内容
if lyricText == "" || len(lyricText) < 10 { if lyricText == "" || len(lyricText) < 10 {
fmt.Printf("[Warning] No lyrics returned from viplrc URL\n") fmt.Printf("[Warning] No lyrics returned from viplrc URL\n")
return false return false
} }
// 将歌词写入文件 // 将歌词写入文件
lyricFilePath := filepath.Join(dirPath, "lyric.lrc") lyricFilePath := filepath.Join(dirPath, "lyric.lrc")
file, err := os.Create(lyricFilePath) file, err := os.Create(lyricFilePath)
@@ -354,14 +354,14 @@ func fetchLyricFromYaohu(songName, artistName, dirPath string) bool {
return false return false
} }
defer file.Close() defer file.Close()
// 写入歌词内容LRC文本格式 // 写入歌词内容LRC文本格式
_, err = file.WriteString(lyricText) _, err = file.WriteString(lyricText)
if err != nil { if err != nil {
fmt.Printf("[Error] Failed to write lyric content: %v\n", err) fmt.Printf("[Error] Failed to write lyric content: %v\n", err)
return false return false
} }
fmt.Printf("[Success] ✅ Lyric fetched from Yaohu QQ Music VIP API and saved to %s\n", lyricFilePath) fmt.Printf("[Success] ✅ Lyric fetched from Yaohu QQ Music VIP API and saved to %s\n", lyricFilePath)
return true return true
} }
@@ -369,50 +369,50 @@ func fetchLyricFromYaohu(songName, artistName, dirPath string) bool {
// getRemoteMusicURLOnly 只获取远程音乐URL不下载不处理用于实时流式播放 // getRemoteMusicURLOnly 只获取远程音乐URL不下载不处理用于实时流式播放
func getRemoteMusicURLOnly(song, singer string) string { func getRemoteMusicURLOnly(song, singer string) string {
fmt.Printf("[Info] Getting remote music URL for: %s - %s\n", singer, song) fmt.Printf("[Info] Getting remote music URL for: %s - %s\n", singer, song)
// 尝试多个 API // 尝试多个 API
apiHosts := []string{ apiHosts := []string{
"https://api.yuafeng.cn", "https://api.yuafeng.cn",
"https://api-v2.yuafeng.cn", "https://api-v2.yuafeng.cn",
} }
sources := []string{"kuwo", "netease", "migu"} sources := []string{"kuwo", "netease", "migu"}
pathMap := map[string]string{ pathMap := map[string]string{
"kuwo": "/API/ly/kwmusic.php", "kuwo": "/API/ly/kwmusic.php",
"netease": "/API/ly/wymusic.php", "netease": "/API/ly/wymusic.php",
"migu": "/API/ly/mgmusic.php", "migu": "/API/ly/mgmusic.php",
} }
client := &http.Client{Timeout: 15 * time.Second} client := &http.Client{Timeout: 15 * time.Second}
for _, host := range apiHosts { for _, host := range apiHosts {
for _, source := range sources { for _, source := range sources {
path := pathMap[source] path := pathMap[source]
apiURL := fmt.Sprintf("%s%s?song=%s&singer=%s", host, path, url.QueryEscape(song), url.QueryEscape(singer)) apiURL := fmt.Sprintf("%s%s?msg=%s-%s&n=1", host, path, url.QueryEscape(song), url.QueryEscape(singer))
resp, err := client.Get(apiURL) resp, err := client.Get(apiURL)
if err != nil { if err != nil {
continue continue
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
continue continue
} }
var response YuafengAPIFreeResponse var response YuafengAPIFreeResponse
if err := json.Unmarshal(body, &response); err != nil { if err := json.Unmarshal(body, &response); err != nil {
continue continue
} }
if response.Data.Music != "" { if response.Data.Music != "" {
fmt.Printf("[Success] Got remote URL from %s: %s\n", source, response.Data.Music) fmt.Printf("[Success] Got remote URL from %s: %s\n", source, response.Data.Music)
return response.Data.Music return response.Data.Music
} }
} }
} }
fmt.Println("[Error] Failed to get remote music URL from all APIs") fmt.Println("[Error] Failed to get remote music URL from all APIs")
return "" return ""
} }