the first version
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -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
|
||||
37
.github/build-and-test.yml
vendored
Normal file
37
.github/build-and-test.yml
vendored
Normal file
@@ -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
|
||||
254
api.go
Normal file
254
api.go
Normal file
@@ -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
|
||||
}
|
||||
1
cache/.gitignore
vendored
Normal file
1
cache/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
108
file.go
Normal file
108
file.go
Normal file
@@ -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)
|
||||
}
|
||||
1
files/.gitignore
vendored
Normal file
1
files/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module MeowEmbedded-MusicServer
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/joho/godotenv v1.5.1
|
||||
283
helper.go
Normal file
283
helper.go
Normal file
@@ -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")
|
||||
}
|
||||
16
httperr.go
Normal file
16
httperr.go
Normal file
@@ -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, "<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)
|
||||
}
|
||||
17
index.go
Normal file
17
index.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", "MeowMusicEmbeddedServer")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Printf("[Web Access] Handling request for %s\n", r.URL.Path)
|
||||
if r.URL.Path != "/" {
|
||||
fileHandler(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "<h1>音乐服务器</h1>")
|
||||
}
|
||||
81
main.go
Normal file
81
main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
const (
|
||||
TAG = "MeowEmbeddedMusicServer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
fmt.Printf("[Warning] %s Loading .env file failed: %v\nUse the default configuration instead.\n", TAG, err)
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
fmt.Printf("[Warning] %s PORT environment variable not set\nUse the default port 2233 instead.\n", TAG)
|
||||
port = "2233"
|
||||
}
|
||||
|
||||
http.HandleFunc("/", indexHandler)
|
||||
http.HandleFunc("/stream_pcm", apiHandler)
|
||||
fmt.Printf("[Info] %s Started.\n喵波音律-音乐家园QQ交流群:865754861\n", TAG)
|
||||
fmt.Printf("[Info] Starting music server at port %s\n", port)
|
||||
|
||||
// Create a channel to listen for signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Create a server instance
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Start the server
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
fmt.Println(err)
|
||||
sigChan <- syscall.SIGINT // Send a signal to shut down the server
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a channel to listen for standard input
|
||||
exitChan := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
var input string
|
||||
fmt.Scanln(&input)
|
||||
if input == "exit" {
|
||||
exitChan <- struct{}{}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Monitor signals or exit signals from standard inputs
|
||||
select {
|
||||
case <-sigChan:
|
||||
fmt.Printf("[Info] Server is shutting down.\nGoodbye!\n")
|
||||
case <-exitChan:
|
||||
fmt.Printf("[Info] Server is shutting down.\nGoodbye!\n")
|
||||
}
|
||||
|
||||
// Shut down the server
|
||||
if err := srv.Shutdown(context.Background()); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
20
sources.json.example
Normal file
20
sources.json.example
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"audio_url": "",
|
||||
"m3u8_url": "",
|
||||
"lyric_url": "",
|
||||
"cover_url": "",
|
||||
"duration": 0
|
||||
},
|
||||
{
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"audio_url": "",
|
||||
"m3u8_url": "",
|
||||
"lyric_url": "",
|
||||
"cover_url": "",
|
||||
"duration": 0
|
||||
}
|
||||
]
|
||||
15
struct.go
Normal file
15
struct.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
// MusicItem represents a music item.
|
||||
type MusicItem struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
AudioURL string `json:"audio_url"`
|
||||
AudioFullURL string `json:"audio_full_url"`
|
||||
M3U8URL string `json:"m3u8_url"`
|
||||
LyricURL string `json:"lyric_url"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Duration int `json:"duration"`
|
||||
FromCache bool `json:"from_cache"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
149
yuafengfreeapi.go
Normal file
149
yuafengfreeapi.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type YuafengAPIFreeResponse struct {
|
||||
Data struct {
|
||||
Song string `json:"song"`
|
||||
Singer string `json:"singer"`
|
||||
Cover string `json:"cover"`
|
||||
AlbumName string `json:"album_name"`
|
||||
Music string `json:"music"`
|
||||
Lyric string `json:"lyric"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// 枫雨API response handler.
|
||||
func YuafengAPIResponseHandler(sources, song, singer string) MusicItem {
|
||||
fmt.Printf("[Info] Fetching music data from 枫林 free API for %s by %s\n", song, singer)
|
||||
var url string
|
||||
switch sources {
|
||||
case "kuwo":
|
||||
url = "https://api.yuafeng.cn/API/ly/kwmusic.php"
|
||||
case "netease":
|
||||
url = "https://api.yuafeng.cn/API/ly/wymusic.php"
|
||||
case "migu":
|
||||
url = "https://api.yuafeng.cn/API/ly/mgmusic.php"
|
||||
case "baidu":
|
||||
url = "https://api.yuafeng.cn/API/ly/bdmusic.php"
|
||||
default:
|
||||
return MusicItem{}
|
||||
}
|
||||
resp, err := http.Get(url + "?msg=" + song + "&n=1")
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error fetching the data from Yuafeng free API:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error reading the response body from Yuafeng free API:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
var response YuafengAPIFreeResponse
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error unmarshalling the data from Yuafeng free API:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
|
||||
// Create directory
|
||||
dirName := fmt.Sprintf("./files/cache/music/%s-%s", response.Data.Singer, response.Data.Song)
|
||||
err = os.MkdirAll(dirName, 0755)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error creating directory:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
|
||||
if response.Data.Music == "" {
|
||||
fmt.Println("[Warning] Music URL is empty")
|
||||
return MusicItem{}
|
||||
}
|
||||
|
||||
// Identify music file format
|
||||
musicExt, err := getMusicFileExtension(response.Data.Music)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error identifying music file format:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
|
||||
// Download music files
|
||||
err = downloadFile(filepath.Join(dirName, "music_full"+musicExt), response.Data.Music)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error downloading music file:", err)
|
||||
}
|
||||
|
||||
// Retrieve music file duration
|
||||
musicFilePath := filepath.Join(dirName, "music_full"+musicExt)
|
||||
duration := getMusicDuration(musicFilePath)
|
||||
|
||||
// Download cover image
|
||||
ext := filepath.Ext(response.Data.Cover)
|
||||
err = downloadFile(filepath.Join(dirName, "cover"+ext), response.Data.Cover)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error downloading cover image:", err)
|
||||
}
|
||||
|
||||
// Check if the lyrics format is in link format
|
||||
lyricData := response.Data.Lyric
|
||||
if lyricData == "获取歌词失败" {
|
||||
// If it is "获取歌词失败", do nothing
|
||||
fmt.Println("[Warning] Lyric retrieval failed, skipping lyric file creation and download.")
|
||||
} else if !strings.HasPrefix(lyricData, "http://") && !strings.HasPrefix(lyricData, "https://") {
|
||||
// If it is not in link format, write the lyrics to the file line by line
|
||||
lines := strings.Split(lyricData, "\r\n")
|
||||
lyricFilePath := filepath.Join(dirName, "lyric.lrc")
|
||||
file, err := os.Create(lyricFilePath)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error creating lyric file:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
for _, line := range lines {
|
||||
_, err := file.WriteString(line + "\r\n")
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error writing to lyric file:", err)
|
||||
return MusicItem{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If it is in link format, download the lyrics file
|
||||
err = downloadFile(filepath.Join(dirName, "lyric.lrc"), lyricData)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error downloading lyric file:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compress and segment audio file
|
||||
err = compressAndSegmentAudio(filepath.Join(dirName, "music_full"+musicExt), dirName)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error compressing and segmenting audio:", err)
|
||||
}
|
||||
|
||||
// Create m3u8 playlist
|
||||
err = createM3U8Playlist(dirName)
|
||||
if err != nil {
|
||||
fmt.Println("[Error] Error creating m3u8 playlist:", err)
|
||||
}
|
||||
|
||||
websiteURL := os.Getenv("WEBSITE_URL")
|
||||
return MusicItem{
|
||||
Title: response.Data.Song,
|
||||
Artist: response.Data.Singer,
|
||||
CoverURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/cover" + ext,
|
||||
LyricURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/lyric.lrc",
|
||||
AudioFullURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music_full" + musicExt,
|
||||
AudioURL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.mp3",
|
||||
M3U8URL: websiteURL + "/cache/music/" + response.Data.Singer + "-" + response.Data.Song + "/music.m3u8",
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user