diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 1a6c4771..60d3aa85 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -3,6 +3,7 @@ package gobackend import ( "encoding/json" "fmt" + "io" "math" "net/http" "net/url" @@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string { // GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ - {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"}, + {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, - {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"}, - {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, - {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"}, - {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"}, + {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"}, + {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"}, + {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"}, + {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"}, } } @@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time { return now.Add(10 * time.Minute) } +func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) { + if len(lines) == 0 { + return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") + } + if syncType == "" { + if len(lines) > 0 && lines[0].StartTimeMs > 0 { + syncType = "LINE_SYNCED" + } else { + syncType = "UNSYNCED" + } + } + return &LyricsResponse{ + Lines: lines, + SyncType: syncType, + Instrumental: false, + PlainLyrics: plainLyrics, + Provider: "Spotify Lyrics API", + Source: "Spotify Lyrics API", + }, nil +} + +func plainLyricsFromTimedLines(lines []LyricsLine) string { + parts := make([]string, 0, len(lines)) + for _, line := range lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + parts = append(parts, words) + } + return strings.Join(parts, "\n") +} + +func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) { + var lrcPayload string + if err := json.Unmarshal(body, &lrcPayload); err == nil { + trimmed := strings.TrimSpace(lrcPayload) + if trimmed == "" { + return nil, fmt.Errorf("Spotify Lyrics API returned empty payload") + } + + lines := parseSyncedLyrics(trimmed) + if len(lines) > 0 { + return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines)) + } + + plainLines := plainTextLyricsLines(trimmed) + return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed) + } + + var apiResp SpotifyLyricsAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) + } + + if apiResp.Error { + msg := strings.TrimSpace(apiResp.Message) + if msg == "" { + msg = "Spotify Lyrics API returned error" + } + return nil, fmt.Errorf("%s", msg) + } + + lines := make([]LyricsLine, 0, len(apiResp.Lines)) + for _, line := range apiResp.Lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) + lines = append(lines, LyricsLine{ + StartTimeMs: startMs, + Words: words, + EndTimeMs: 0, + }) + } + + for i := 0; i < len(lines)-1; i++ { + nextStart := lines[i+1].StartTimeMs + if nextStart > lines[i].StartTimeMs { + lines[i].EndTimeMs = nextStart + } + } + if len(lines) > 0 { + last := len(lines) - 1 + if lines[last].EndTimeMs == 0 { + lines[last].EndTimeMs = lines[last].StartTimeMs + 5000 + } + } + + return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines)) +} + func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { now := time.Now() if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { @@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo spotifyID = parsed.ID } - apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID)) + apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID)) req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo } defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err) + } + if resp.StatusCode != 200 { if resp.StatusCode == http.StatusTooManyRequests { retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) setSpotifyLyricsRateLimitUntil(retryUntil) } var payload map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil { + if err := json.Unmarshal(bodyBytes, &payload); err == nil { if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) } @@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) } - var apiResp SpotifyLyricsAPIResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { - return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) - } - - if apiResp.Error { - msg := strings.TrimSpace(apiResp.Message) - if msg == "" { - msg = "Spotify Lyrics API returned error" - } - return nil, fmt.Errorf("%s", msg) - } - - result := &LyricsResponse{ - Lines: make([]LyricsLine, 0, len(apiResp.Lines)), - SyncType: apiResp.SyncType, - Instrumental: false, - PlainLyrics: "", - Provider: "Spotify Lyrics API", - Source: "Spotify Lyrics API", - } - - for _, line := range apiResp.Lines { - words := strings.TrimSpace(line.Words) - if words == "" { - continue - } - startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) - result.Lines = append(result.Lines, LyricsLine{ - StartTimeMs: startMs, - Words: words, - EndTimeMs: 0, - }) - } - - if len(result.Lines) > 1 { - for i := 0; i < len(result.Lines)-1; i++ { - nextStart := result.Lines[i+1].StartTimeMs - if nextStart > result.Lines[i].StartTimeMs { - result.Lines[i].EndTimeMs = nextStart - } - } - last := len(result.Lines) - 1 - if result.Lines[last].EndTimeMs == 0 { - result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000 - } - } - - if len(result.Lines) == 0 { - return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") - } - - if result.SyncType == "" { - result.SyncType = "LINE_SYNCED" - } - - return result, nil + return parseSpotifyLyricsResponseBody(bodyBytes) } func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 538b0b89..8fe350ad 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -4,121 +4,25 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "net/url" - "regexp" "strings" - "sync" "time" ) // AppleMusicClient fetches lyrics from Apple Music. -// Uses a scraped JWT token for search and a proxy for lyrics. +// Uses Paxsenix endpoints for search and lyrics. type AppleMusicClient struct { httpClient *http.Client } -// Apple Music token manager — singleton with mutex for thread safety -type appleTokenManager struct { - mu sync.Mutex - token string -} - -var globalAppleTokenManager = &appleTokenManager{} - -func (m *appleTokenManager) getToken(client *http.Client) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.token != "" { - return m.token, nil - } - - // Step 1: Fetch the Apple Music beta page - req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch Apple Music page: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read Apple Music page: %w", err) - } - - // Step 2: Find the index JS file URL - indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`) - match := indexJsRegex.Find(body) - if match == nil { - return "", fmt.Errorf("could not find index JS script URL on Apple Music page") - } - - indexJsURL := "https://beta.music.apple.com" + string(match) - - // Step 3: Fetch the JS file - jsReq, err := http.NewRequest("GET", indexJsURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create JS request: %w", err) - } - jsReq.Header.Set("User-Agent", getRandomUserAgent()) - - jsResp, err := client.Do(jsReq) - if err != nil { - return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err) - } - defer jsResp.Body.Close() - - jsBody, err := io.ReadAll(jsResp.Body) - if err != nil { - return "", fmt.Errorf("failed to read Apple Music JS: %w", err) - } - - // Step 4: Extract JWT token (starts with eyJh) - tokenRegex := regexp.MustCompile(`eyJh[^"]*`) - tokenMatch := tokenRegex.Find(jsBody) - if tokenMatch == nil { - return "", fmt.Errorf("could not find JWT token in Apple Music JS") - } - - m.token = string(tokenMatch) - GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token)) - return m.token, nil -} - -func (m *appleTokenManager) clearToken() { - m.mu.Lock() - defer m.mu.Unlock() - m.token = "" -} - -type appleMusicSearchResponse struct { - Results struct { - Songs *struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - } `json:"data"` - } `json:"songs"` - } `json:"results"` - Resources *struct { - Songs map[string]struct { - Attributes struct { - Name string `json:"name"` - ArtistName string `json:"artistName"` - AlbumName string `json:"albumName"` - URL string `json:"url"` - Artwork struct { - URL string `json:"url"` - } `json:"artwork"` - } `json:"attributes"` - } `json:"songs"` - } `json:"resources"` +type appleMusicSearchResult struct { + ID string `json:"id"` + SongName string `json:"songName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration int `json:"duration"` } // PaxResponse represents the lyrics proxy response for word-by-word / line lyrics @@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient { } } +func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult { + if len(results) == 0 { + return nil + } + + normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName))) + normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName))) + if normalizedArtist == "" { + normalizedArtist = strings.ToLower(strings.TrimSpace(artistName)) + } + + bestIndex := 0 + bestScore := -1 + for i := range results { + result := &results[i] + score := 0 + + candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName))) + candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName))) + + switch { + case candidateTrack == normalizedTrack: + score += 50 + case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack): + score += 25 + } + + switch { + case candidateArtist == normalizedArtist: + score += 60 + case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist): + score += 30 + } + + if durationSec > 0 && result.Duration > 0 { + diff := math.Abs(float64(result.Duration)/1000.0 - durationSec) + if diff <= durationToleranceSec { + score += 20 + } + } + + if score > bestScore { + bestScore = score + bestIndex = i + } + } + + return &results[bestIndex] +} + // SearchSong searches for a song on Apple Music and returns its ID. -func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) { +func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := trackName + " " + artistName if strings.TrimSpace(query) == "" { return "", fmt.Errorf("empty search query") } - token, err := globalAppleTokenManager.getToken(c.httpClient) - if err != nil { - return "", fmt.Errorf("apple music token error: %w", err) - } - encodedQuery := url.QueryEscape(query) - searchURL := fmt.Sprintf( - "https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl", - encodedQuery, - ) + searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery) req, err := http.NewRequest("GET", searchURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Origin", "https://music.apple.com") - req.Header.Set("Referer", "https://music.apple.com/") req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("Accept", "application/json") @@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err } defer resp.Body.Close() - if resp.StatusCode == 401 { - globalAppleTokenManager.clearToken() - return "", fmt.Errorf("apple music token expired") - } - if resp.StatusCode != 200 { return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode) } - var searchResp appleMusicSearchResponse + var searchResp []appleMusicSearchResult if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { return "", fmt.Errorf("failed to decode apple music response: %w", err) } - if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 { + best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec) + if best == nil || strings.TrimSpace(best.ID) == "" { return "", fmt.Errorf("no songs found on apple music") } - return searchResp.Results.Songs.Data[0].ID, nil + return strings.TrimSpace(best.ID), nil } // FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID. @@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics( durationSec float64, multiPersonWordByWord bool, ) (*LyricsResponse, error) { - songID, err := c.SearchSong(trackName, artistName) + songID, err := c.SearchSong(trackName, artistName, durationSec) if err != nil { return nil, err } diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index f962a110..37dfbea7 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -3,6 +3,8 @@ package gobackend import ( "encoding/json" "fmt" + "io" + "math" "net/http" "net/url" "strings" @@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct { func NewMusixmatchClient() *MusixmatchClient { return &MusixmatchClient{ httpClient: NewMetadataHTTPClient(15 * time.Second), - baseURL: "http://158.180.60.95", + baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics", } } -// searchAndGetLyrics searches for a song and retrieves its lyrics in one call. -// The Musixmatch proxy returns both search result and lyrics in a single response. -func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) { +func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) { if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { - return nil, fmt.Errorf("empty track or artist name") + return "", fmt.Errorf("empty track or artist name") } - encodedArtist := url.QueryEscape(artistName) - encodedTrack := url.QueryEscape(trackName) - - fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack) + params := url.Values{} + params.Set("t", trackName) + params.Set("a", artistName) + params.Set("type", lyricsType) + params.Set("format", "lrc") + if durationSec > 0 { + params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec)))) + } + if strings.TrimSpace(language) != "" { + params.Set("l", strings.ToLower(strings.TrimSpace(language))) + } + fullURL := c.baseURL + "?" + params.Encode() req, err := http.NewRequest("GET", fullURL, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return "", fmt.Errorf("failed to create request: %w", err) } + req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("musixmatch search failed: %w", err) + return "", fmt.Errorf("musixmatch request failed: %w", err) } defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read musixmatch response: %w", err) + } + if resp.StatusCode != 200 { - return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) + trimmed := strings.TrimSpace(string(body)) + if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { + return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg) + } + return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) } - var result musixmatchSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode musixmatch response: %w", err) + var lrcPayload string + if err := json.Unmarshal(body, &lrcPayload); err == nil { + lrcPayload = strings.TrimSpace(lrcPayload) + if lrcPayload == "" { + return "", fmt.Errorf("empty musixmatch lyrics payload") + } + return lrcPayload, nil } - return &result, nil + trimmed := strings.TrimSpace(string(body)) + if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { + return "", fmt.Errorf("%s", errMsg) + } + if trimmed != "" && !strings.HasPrefix(trimmed, "{") { + return trimmed, nil + } + return "", fmt.Errorf("failed to decode musixmatch response") } // FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code. -func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) { +func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) { lang := strings.ToLower(strings.TrimSpace(language)) - if songID <= 0 || lang == "" { - return nil, fmt.Errorf("invalid song id or language") + if lang == "" { + return nil, fmt.Errorf("invalid language") } - fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang)) - - req, err := http.NewRequest("GET", fullURL, nil) + lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("musixmatch language fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode) + return nil, err } - var result musixmatchSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + PlainLyrics: plainLyricsFromTimedLines(lines), + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil } - if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { - lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil - } - } - - if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) - - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "UNSYNCED", - PlainLyrics: result.UnsyncedLyrics.Lyrics, - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil - } + plainLines := plainTextLyricsLines(lrcText) + if len(plainLines) > 0 { + return &LyricsResponse{ + Lines: plainLines, + SyncType: "UNSYNCED", + PlainLyrics: lrcText, + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil } return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) @@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) // FetchLyrics searches Musixmatch and returns parsed LyricsResponse. func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { - result, err := c.searchAndGetLyrics(trackName, artistName) - if err != nil { - return nil, err - } - - if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 { - localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred) + if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" { + localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred) if localizedErr == nil { return localized, nil } GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) } - if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { - lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil - } + lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "") + if err != nil { + return nil, err } - if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { - lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + PlainLyrics: plainLyricsFromTimedLines(lines), + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil + } - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "UNSYNCED", - PlainLyrics: result.UnsyncedLyrics.Lyrics, - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil - } + plainLines := plainTextLyricsLines(lrcText) + if len(plainLines) > 0 { + return &LyricsResponse{ + Lines: plainLines, + SyncType: "UNSYNCED", + PlainLyrics: lrcText, + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil } return nil, fmt.Errorf("no lyrics found on musixmatch") diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index f6ce6b6c..c6741e19 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -9,8 +9,7 @@ import ( "time" ) -// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com). -// This is a direct public API — no proxy dependency. +// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints. type NeteaseClient struct { httpClient *http.Client } @@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) return 0, fmt.Errorf("empty search query") } - searchURL := "http://music.163.com/api/search/pc" + searchURL := "https://lyrics.paxsenix.org/netease/search" params := url.Values{} - params.Set("s", query) - params.Set("type", "1") - params.Set("limit", "1") - params.Set("offset", "0") + params.Set("q", query) fullURL := searchURL + "?" + params.Encode() @@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) // FetchLyricsByID fetches synced lyrics for a given Netease song ID. func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) { - lyricsURL := "http://music.163.com/api/song/lyric" + lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics" params := url.Values{} params.Set("id", fmt.Sprintf("%d", songID)) - params.Set("lv", "1") - params.Set("tv", "1") - params.Set("rv", "1") fullURL := lyricsURL + "?" + params.Encode() diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index dde3631d..95461031 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -1,45 +1,31 @@ package gobackend import ( - "bytes" "encoding/json" "fmt" "io" + "math" "net/http" - "net/url" "strings" "time" ) // QQMusicClient fetches lyrics from QQ Music. -// Search uses public QQ Music API, lyrics use the paxsenix proxy. +// Uses Paxsenix metadata lookup for lyrics. type QQMusicClient struct { httpClient *http.Client } -type qqMusicSearchResponse struct { - Data struct { - Song struct { - List []struct { - Title string `json:"title"` - Singer []struct { - Name string `json:"name"` - } `json:"singer"` - Album struct { - Name string `json:"name"` - } `json:"album"` - ID int64 `json:"id"` - } `json:"list"` - } `json:"song"` - } `json:"data"` +type qqLyricsMetadataRequest struct { + Artist []string `json:"artist"` + Album string `json:"album,omitempty"` + SongID int64 `json:"songid,omitempty"` + Title string `json:"title"` + Duration int64 `json:"duration,omitempty"` } -// QQ Music lyrics request payload for paxsenix proxy -type qqLyricsPayload struct { - Artist []string `json:"artist"` - Album string `json:"album"` - ID int64 `json:"id"` - Title string `json:"title"` +type qqLyricsMetadataResponse struct { + Lyrics []paxLyrics `json:"lyrics"` } func NewQQMusicClient() *QQMusicClient { @@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient { } } -// searchSong searches QQ Music and returns the song info needed for lyrics fetch. -func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) { - query := trackName + " " + artistName - if strings.TrimSpace(query) == "" { - return nil, fmt.Errorf("empty search query") +// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata. +func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) { + payload := qqLyricsMetadataRequest{ + Artist: []string{artistName}, + Title: trackName, + } + if durationSec > 0 { + payload.Duration = int64(math.Round(durationSec)) } - searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" - params := url.Values{} - params.Set("format", "json") - params.Set("inCharset", "utf8") - params.Set("outCharset", "utf8") - params.Set("platform", "yqq.json") - params.Set("new_json", "1") - params.Set("w", query) - - fullURL := searchURL + "?" + params.Encode() - - req, err := http.NewRequest("GET", fullURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("qqmusic search failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode) - } - - var searchResp qqMusicSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return nil, fmt.Errorf("failed to decode qqmusic response: %w", err) - } - - if len(searchResp.Data.Song.List) == 0 { - return nil, fmt.Errorf("no songs found on qqmusic") - } - - song := searchResp.Data.Song.List[0] - - var artists []string - for _, singer := range song.Singer { - artists = append(artists, singer.Name) - } - - return &qqLyricsPayload{ - Artist: artists, - Album: song.Album.Name, - ID: song.ID, - Title: song.Title, - }, nil -} - -// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info. -func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) { - lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php" + lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata" payloadBytes, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("failed to marshal payload: %w", err) } - req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes)) + req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes))) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) @@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, return bodyStr, nil } +func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) { + var response qqLyricsMetadataResponse + if err := json.Unmarshal([]byte(rawJSON), &response); err != nil { + return "", fmt.Errorf("failed to parse qq metadata lyrics response") + } + if len(response.Lyrics) == 0 { + return "", fmt.Errorf("qq metadata lyrics response was empty") + } + return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil +} + // FetchLyrics searches QQ Music and returns parsed LyricsResponse. func (c *QQMusicClient) FetchLyrics( trackName, @@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics( durationSec float64, multiPersonWordByWord bool, ) (*LyricsResponse, error) { - payload, err := c.searchSong(trackName, artistName) - if err != nil { - return nil, err - } - - rawLyrics, err := c.fetchLyricsByPayload(payload) + rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec) if err != nil { return nil, err } @@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics( return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg) } - // Try to parse as pax format (word-by-word or line) - lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord) + lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord) if err != nil { - // If pax parsing fails, try to use as direct LRC text - lrcText = rawLyrics + if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil { + lrcText = fallback + } else { + lrcText = rawLyrics + } } lines := parseSyncedLyrics(lrcText)