diff --git a/go_backend/lyrics_config.go b/go_backend/lyrics_config.go index 42b83ae8..cf0de0a2 100644 --- a/go_backend/lyrics_config.go +++ b/go_backend/lyrics_config.go @@ -288,12 +288,12 @@ func GetAvailableLyricsProviders() []map[string]any { {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"}, {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"}, - {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"}, + {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": false, "description": "Direct QQ Music line-synced lyrics"}, {"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"}, {"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"}, {"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"}, - {"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"}, - {"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"}, + {"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": false, "description": "Direct Kugou synced lyrics"}, + {"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": false, "description": "Direct Genius lyrics"}, {"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"}, } } diff --git a/go_backend/lyrics_genius.go b/go_backend/lyrics_genius.go new file mode 100644 index 00000000..784b99e5 --- /dev/null +++ b/go_backend/lyrics_genius.go @@ -0,0 +1,123 @@ +package gobackend + +import ( + "fmt" + "io" + "net/http" + "strings" + + "golang.org/x/net/html" +) + +const maxGeniusPageBytes = 8 << 20 + +func geniusLyricsContainer(node *html.Node) bool { + if node.Type != html.ElementNode || node.Data != "div" { + return false + } + for _, attr := range node.Attr { + if attr.Key == "data-lyrics-container" && attr.Val == "true" { + return true + } + } + return false +} + +func geniusExcludedNode(node *html.Node) bool { + for _, attr := range node.Attr { + if attr.Key == "data-exclude-from-selection" && attr.Val == "true" { + return true + } + } + return false +} + +func appendGeniusText(builder *strings.Builder, node *html.Node) { + if geniusExcludedNode(node) { + return + } + if node.Type == html.TextNode { + builder.WriteString(node.Data) + return + } + if node.Type == html.ElementNode && node.Data == "br" { + builder.WriteByte('\n') + return + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + appendGeniusText(builder, child) + } +} + +func geniusLyricsFromHTML(body io.Reader) (string, error) { + document, err := html.Parse(body) + if err != nil { + return "", fmt.Errorf("failed to parse Genius page: %w", err) + } + var containers []*html.Node + var walk func(*html.Node) + walk = func(node *html.Node) { + if geniusLyricsContainer(node) { + containers = append(containers, node) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(document) + if len(containers) == 0 { + return "", lyricsNotFoundErrorf("Genius page has no lyrics container") + } + + // Genius renders each verse/chorus as a separate lyrics container. Preserve + // document order and join all usable containers into one LRC/plain payload. + var sections []string + for _, container := range containers { + var builder strings.Builder + appendGeniusText(&builder, container) + candidate := strings.TrimSpace(strings.ReplaceAll(builder.String(), "\u00a0", " ")) + if rawLyricsHasUsableContent(candidate) { + sections = append(sections, candidate) + } + } + if len(sections) == 0 { + return "", lyricsNotFoundErrorf("Genius page returned empty lyrics") + } + return strings.Join(sections, "\n"), nil +} + +func (c *GeniusLyricsClient) fetchLyricsFromPage(pageURL string) (*LyricsResponse, error) { + pageURL = strings.TrimSpace(pageURL) + if pageURL == "" { + return nil, lyricsNotFoundErrorf("empty Genius lyrics URL") + } + req, err := http.NewRequest(http.MethodGet, pageURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create Genius page request: %w", err) + } + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("User-Agent", getRandomUserAgent()) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Genius page request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, lyricsServiceUnavailableErrorf("Genius page returned HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxGeniusPageBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read Genius page: %w", err) + } + if len(data) > maxGeniusPageBytes { + return nil, lyricsServiceUnavailableErrorf("Genius page exceeds %d bytes", maxGeniusPageBytes) + } + lrc, err := geniusLyricsFromHTML(strings.NewReader(string(data))) + if err != nil { + return nil, err + } + lyrics := lyricsResponseFromText(lrc, "Genius") + lyrics.Source = "Genius Direct" + return lyrics, nil +} diff --git a/go_backend/lyrics_kugou.go b/go_backend/lyrics_kugou.go new file mode 100644 index 00000000..35b0bb25 --- /dev/null +++ b/go_backend/lyrics_kugou.go @@ -0,0 +1,227 @@ +package gobackend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const maxKugouLyricsResponseBytes = 2 << 20 + +type KugouLyricsClient struct { + httpClient *http.Client +} + +type kugouLyricsSearchResult struct { + ID string `json:"id"` + AccessKey string `json:"accesskey"` + Title string `json:"song"` + Artist string `json:"singer"` + Duration float64 `json:"duration"` +} + +type kugouLyricsSearchResponse struct { + Status int `json:"status"` + ErrorCode int `json:"errcode"` + Error string `json:"errmsg"` + Candidates []kugouLyricsSearchResult `json:"candidates"` +} + +type kugouLyricsDownloadResponse struct { + Status int `json:"status"` + ErrorCode int `json:"error_code"` + Info string `json:"info"` + Content string `json:"content"` +} + +func NewKugouLyricsClient() *KugouLyricsClient { + return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} +} + +func fetchKugouLyricsBody(httpClient *http.Client, endpoint string, params url.Values) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, endpoint+"?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", appUserAgent()) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, lyricsServiceUnavailableErrorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxKugouLyricsResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if len(body) > maxKugouLyricsResponseBytes { + return nil, lyricsServiceUnavailableErrorf( + "response exceeds %d bytes", + maxKugouLyricsResponseBytes, + ) + } + if strings.TrimSpace(string(body)) == "" { + return nil, lyricsServiceUnavailableErrorf("empty response") + } + return body, nil +} + +func (c *KugouLyricsClient) SearchSong( + trackName, + artistName string, + durationSec float64, +) (*kugouLyricsSearchResult, error) { + query := strings.TrimSpace(artistName + " - " + trackName) + if query == "" { + return nil, lyricsNotFoundErrorf("empty search query") + } + + params := url.Values{ + "ver": {"1"}, + "man": {"yes"}, + "client": {"pc"}, + "keyword": {query}, + "duration": {strconv.FormatInt(int64(math.Round(durationSec*1000)), 10)}, + "hash": {""}, + } + raw, err := fetchKugouLyricsBody( + c.httpClient, + "https://lyrics.kugou.com/search", + params, + ) + if err != nil { + return nil, fmt.Errorf("kugou search failed: %w", err) + } + + var response kugouLyricsSearchResponse + if err := json.Unmarshal(raw, &response); err != nil { + return nil, fmt.Errorf("failed to decode kugou search: %w", err) + } + // KuGou uses errcode=200 for a successful search response, while some + // mirrors omit the field (or return zero). Treat both success forms as + // valid and only reject explicit non-success codes. + if response.Status != http.StatusOK || + (response.ErrorCode != 0 && response.ErrorCode != http.StatusOK) { + message := strings.TrimSpace(response.Error) + if message == "" { + message = fmt.Sprintf( + "status %d/error %d", + response.Status, + response.ErrorCode, + ) + } + return nil, lyricsServiceUnavailableErrorf("%s", message) + } + + best := selectBestKugouLyricsSearchResult( + response.Candidates, + trackName, + artistName, + durationSec, + ) + if best == nil || + strings.TrimSpace(best.ID) == "" || + strings.TrimSpace(best.AccessKey) == "" { + return nil, lyricsNotFoundErrorf("no matching song found on kugou") + } + return best, nil +} + +func selectBestKugouLyricsSearchResult( + results []kugouLyricsSearchResult, + trackName, + artistName string, + durationSec float64, +) *kugouLyricsSearchResult { + best := selectBestLyricsCandidate( + len(results), + trackName, + artistName, + durationSec, + func(i int) (string, string, float64, bool) { + result := &results[i] + durationSeconds := result.Duration / 1000 + matches := lyricsSearchTitlesMatch(result.Title, trackName, false) && + lyricsSearchArtistsMatch(result.Artist, artistName) && + lyricsSearchDurationMatches(durationSeconds, durationSec) + return result.Title, result.Artist, durationSeconds, matches + }, + ) + if best < 0 { + return nil + } + return &results[best] +} + +func (c *KugouLyricsClient) FetchLyrics( + trackName, + artistName string, + durationSec float64, +) (*LyricsResponse, error) { + match, err := c.SearchSong(trackName, artistName, durationSec) + if err != nil { + return nil, err + } + + params := url.Values{ + "ver": {"1"}, + "client": {"pc"}, + "id": {match.ID}, + "accesskey": {match.AccessKey}, + "fmt": {"lrc"}, + "charset": {"utf8"}, + } + raw, err := fetchKugouLyricsBody( + c.httpClient, + "https://lyrics.kugou.com/download", + params, + ) + if err != nil { + return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err) + } + + var response kugouLyricsDownloadResponse + if err := json.Unmarshal(raw, &response); err != nil { + return nil, fmt.Errorf("failed to decode kugou lyrics: %w", err) + } + if response.Status != http.StatusOK || response.ErrorCode != 0 { + message := strings.TrimSpace(response.Info) + if message == "" { + message = fmt.Sprintf( + "status %d/error %d", + response.Status, + response.ErrorCode, + ) + } + return nil, lyricsServiceUnavailableErrorf("%s", message) + } + + decoded, err := base64.StdEncoding.DecodeString(response.Content) + if err != nil { + return nil, lyricsServiceUnavailableErrorf( + "invalid base64 lyrics: %v", + err, + ) + } + lyrics := lyricsResponseFromLRCText( + string(decoded), + "Kugou", + "Kugou Direct", + ) + if !lyricsHasUsableText(lyrics) { + return nil, lyricsNotFoundErrorf("kugou returned empty lyrics") + } + return lyrics, nil +} diff --git a/go_backend/lyrics_lrc.go b/go_backend/lyrics_lrc.go index d0613bf0..432361fd 100644 --- a/go_backend/lyrics_lrc.go +++ b/go_backend/lyrics_lrc.go @@ -220,6 +220,9 @@ func lyricsSourceUsesPaxsenix(source string) bool { return false } if strings.HasPrefix(s, "lrclib") || + strings.HasPrefix(s, "kugou direct") || + strings.HasPrefix(s, "qq music direct") || + strings.HasPrefix(s, "genius direct") || strings.HasPrefix(s, "extension:") || strings.HasPrefix(s, "heuristic") { return false diff --git a/go_backend/lyrics_paxsenix.go b/go_backend/lyrics_paxsenix.go index 04edf6fe..c73b4b9a 100644 --- a/go_backend/lyrics_paxsenix.go +++ b/go_backend/lyrics_paxsenix.go @@ -25,10 +25,6 @@ type YouTubeLyricsClient struct { httpClient *http.Client } -type KugouLyricsClient struct { - httpClient *http.Client -} - type GeniusLyricsClient struct { httpClient *http.Client } @@ -47,13 +43,6 @@ type youtubeLyricsSearchResult struct { Duration string `json:"duration"` } -type kugouLyricsSearchResult struct { - Hash string `json:"hash"` - Title string `json:"title"` - Artist string `json:"artist"` - Duration float64 `json:"duration"` -} - type geniusSearchResponse struct { Response struct { Sections []struct { @@ -90,10 +79,6 @@ func NewYouTubeLyricsClient() *YouTubeLyricsClient { return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} } -func NewKugouLyricsClient() *KugouLyricsClient { - return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} -} - func NewGeniusLyricsClient() *GeniusLyricsClient { return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} } @@ -433,59 +418,6 @@ func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, duration return parsePaxsenixLyricsPayload(raw, "YouTube", false) } -func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { - query := strings.TrimSpace(trackName + " " + artistName) - if query == "" { - return "", lyricsNotFoundErrorf("empty search query") - } - - params := url.Values{} - params.Set("q", query) - raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params) - if err != nil { - return "", fmt.Errorf("kugou search failed: %w", err) - } - - var results []kugouLyricsSearchResult - if err := json.Unmarshal([]byte(raw), &results); err != nil { - return "", fmt.Errorf("failed to decode kugou search: %w", err) - } - best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec) - if best == nil || strings.TrimSpace(best.Hash) == "" { - return "", lyricsNotFoundErrorf("no songs found on kugou") - } - return strings.TrimSpace(best.Hash), nil -} - -func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult { - best := selectBestLyricsCandidate(len(results), trackName, artistName, durationSec, func(i int) (string, string, float64, bool) { - result := &results[i] - ok := lyricsSearchTitlesMatch(result.Title, trackName, false) && - lyricsSearchArtistsMatch(result.Artist, artistName) && - lyricsSearchDurationMatches(result.Duration, durationSec) - return result.Title, result.Artist, result.Duration, ok - }) - if best < 0 { - return nil - } - return &results[best] -} - -func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) { - hash, err := c.SearchSong(trackName, artistName, durationSec) - if err != nil { - return nil, err - } - - params := url.Values{} - params.Set("id", hash) - raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params) - if err != nil { - return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err) - } - return parsePaxsenixLyricsPayload(raw, "Kugou", false) -} - func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := strings.TrimSpace(trackName + " " + artistName) if query == "" { @@ -544,18 +476,7 @@ func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationS if err != nil { return nil, err } - - params := url.Values{} - params.Set("url", geniusURL) - // The legacy v1 contract can report success with an empty lyrics string. - // v2 keeps the same string payload shape while using the maintained - // normalized Genius extractor. - params.Set("v", "2") - raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params) - if err != nil { - return nil, fmt.Errorf("genius lyrics fetch failed: %w", err) - } - return parsePaxsenixLyricsPayload(raw, "Genius", false) + return c.fetchLyricsFromPage(geniusURL) } func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int { diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index a6b6d19a..fb47db59 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -1,120 +1,188 @@ package gobackend import ( + "encoding/base64" "encoding/json" "fmt" "io" - "math" "net/http" + "net/url" + "strconv" "strings" "time" ) +const ( + qqMusicSearchURL = "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" + qqMusicLyricsURL = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg" + maxQQMusicResponseSize = 2 << 20 +) + +// QQMusicClient fetches line-synchronised lyrics from QQ Music's public web +// endpoints. Word-level timing remains available through lyrics extensions. type QQMusicClient struct { httpClient *http.Client } -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"` +type qqMusicSearchResult struct { + Mid string `json:"mid"` + ID int64 `json:"id"` + Name string `json:"name"` + Interval int `json:"interval"` + Singer []struct { + Name string `json:"name"` + } `json:"singer"` } -type qqLyricsMetadataResponse struct { - Lyrics []paxLyrics `json:"lyrics"` +type qqMusicSearchResponse struct { + Code int `json:"code"` + Data struct { + Song struct { + List []qqMusicSearchResult `json:"list"` + } `json:"song"` + } `json:"data"` +} + +type qqMusicLyricsResponse struct { + RetCode int `json:"retcode"` + Code int `json:"code"` + Lyric string `json:"lyric"` } func NewQQMusicClient() *QQMusicClient { - return &QQMusicClient{ - httpClient: NewMetadataHTTPClient(15 * time.Second), - } + return &QQMusicClient{httpClient: NewMetadataHTTPClient(15 * time.Second)} } -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)) - } - - lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata" - - payloadBytes, err := json.Marshal(payload) +func fetchQQMusicBody(client *http.Client, endpoint string, params url.Values) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, endpoint+"?"+params.Encode(), nil) if err != nil { - return "", fmt.Errorf("failed to marshal payload: %w", err) + return nil, fmt.Errorf("failed to create QQ Music request: %w", err) } - - 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", appUserAgent()) + req.Header.Set("Referer", "https://y.qq.com/") + req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", lyricsHTTPStatusError(resp.StatusCode, "qqmusic lyrics proxy returned HTTP %d", resp.StatusCode) - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read lyrics response: %w", err) - } - - bodyStr := strings.TrimSpace(string(bodyBytes)) - if bodyStr == "" { - return "", fmt.Errorf("empty lyrics response from qqmusic") - } - - 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, true), nil -} - -func (c *QQMusicClient) FetchLyrics( - trackName, - artistName string, - durationSec float64, - multiPersonWordByWord bool, -) (*LyricsResponse, error) { - rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec) + resp, err := client.Do(req) if err != nil { return nil, err } - if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload { - return nil, classifyLyricsPayloadError(0, errMsg, "qqmusic proxy returned non-lyric payload: %s", errMsg) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, lyricsServiceUnavailableErrorf("QQ Music returned HTTP %d", resp.StatusCode) } - lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxQQMusicResponseSize+1)) if err != nil { - if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil { - lrcText = fallback - } else { - lrcText = rawLyrics - } + return nil, fmt.Errorf("failed to read QQ Music response: %w", err) + } + if len(body) > maxQQMusicResponseSize { + return nil, lyricsServiceUnavailableErrorf("QQ Music response exceeds %d bytes", maxQQMusicResponseSize) + } + if len(strings.TrimSpace(string(body))) == 0 { + return nil, lyricsServiceUnavailableErrorf("QQ Music returned an empty response") + } + return body, nil +} + +func (c *QQMusicClient) SearchSong(trackName, artistName string, durationSec float64) (*qqMusicSearchResult, error) { + query := strings.TrimSpace(trackName + " " + artistName) + if query == "" { + return nil, lyricsNotFoundErrorf("empty search query") } - if resp := lyricsResponseFromLRCText(lrcText, "QQ Music", "QQ Music"); resp != nil { - return resp, nil + params := url.Values{ + "format": {"json"}, "inCharset": {"utf8"}, "outCharset": {"utf8"}, + "platform": {"yqq.json"}, "new_json": {"1"}, "w": {query}, + "p": {"1"}, "n": {"20"}, "t": {"0"}, "aggr": {"1"}, + "cr": {"1"}, "catZhida": {"1"}, "lossless": {"1"}, + "flag_qc": {"0"}, "remoteplace": {"txt.yqq.center"}, "needNewCode": {"0"}, } - return nil, lyricsNotFoundErrorf("no lyrics found on qqmusic") + raw, err := fetchQQMusicBody(c.httpClient, qqMusicSearchURL, params) + if err != nil { + return nil, fmt.Errorf("QQ Music search failed: %w", err) + } + var response qqMusicSearchResponse + if err := json.Unmarshal(raw, &response); err != nil { + return nil, fmt.Errorf("failed to decode QQ Music search: %w", err) + } + if response.Code != 0 { + return nil, lyricsServiceUnavailableErrorf("QQ Music search returned code %d", response.Code) + } + best := selectBestQQMusicSearchResult(response.Data.Song.List, trackName, artistName, durationSec) + if best == nil || strings.TrimSpace(best.Mid) == "" { + return nil, lyricsNotFoundErrorf("no matching song found on QQ Music") + } + return best, nil +} + +func selectBestQQMusicSearchResult(results []qqMusicSearchResult, trackName, artistName string, durationSec float64) *qqMusicSearchResult { + best := selectBestLyricsCandidate(len(results), trackName, artistName, durationSec, func(i int) (string, string, float64, bool) { + result := &results[i] + artists := make([]string, 0, len(result.Singer)) + for _, singer := range result.Singer { + if name := strings.TrimSpace(singer.Name); name != "" { + artists = append(artists, name) + } + } + candidateArtist := strings.Join(artists, ", ") + duration := float64(result.Interval) + matches := lyricsSearchTitlesMatch(result.Name, trackName, false) && + lyricsSearchArtistsMatch(candidateArtist, artistName) && + lyricsSearchDurationMatches(duration, durationSec) + return result.Name, candidateArtist, duration, matches + }) + if best < 0 { + return nil + } + return &results[best] +} + +func decodeQQMusicLyric(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", lyricsNotFoundErrorf("QQ Music returned empty lyrics") + } + if strings.HasPrefix(raw, "[") { + return raw, nil + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + if decoded, rawErr := base64.RawStdEncoding.DecodeString(raw); rawErr == nil { + return string(decoded), nil + } + return "", lyricsServiceUnavailableErrorf("invalid QQ Music lyrics encoding") + } + return string(decoded), nil +} + +func (c *QQMusicClient) FetchLyrics(trackName, artistName string, durationSec float64, _ bool) (*LyricsResponse, error) { + match, err := c.SearchSong(trackName, artistName, durationSec) + if err != nil { + return nil, err + } + params := url.Values{ + "format": {"json"}, "inCharset": {"utf8"}, "outCharset": {"utf-8"}, + "notice": {"0"}, "platform": {"yqq.json"}, "needNewCode": {"0"}, + "songmid": {match.Mid}, "songid": {strconv.FormatInt(match.ID, 10)}, + } + raw, err := fetchQQMusicBody(c.httpClient, qqMusicLyricsURL, params) + if err != nil { + return nil, fmt.Errorf("QQ Music lyrics fetch failed: %w", err) + } + var response qqMusicLyricsResponse + if err := json.Unmarshal(raw, &response); err != nil { + return nil, fmt.Errorf("failed to decode QQ Music lyrics: %w", err) + } + if response.RetCode != 0 || response.Code != 0 { + return nil, lyricsServiceUnavailableErrorf("QQ Music lyrics returned code %d", response.Code) + } + lrc, err := decodeQQMusicLyric(response.Lyric) + if err != nil { + return nil, err + } + lyrics := lyricsResponseFromLRCText(lrc, "QQ Music", "QQ Music Direct") + if !lyricsHasUsableText(lyrics) { + return nil, lyricsNotFoundErrorf("no lyrics found on QQ Music") + } + return lyrics, nil } diff --git a/go_backend/lyrics_search_matching_test.go b/go_backend/lyrics_search_matching_test.go index f739684f..907d4e5f 100644 --- a/go_backend/lyrics_search_matching_test.go +++ b/go_backend/lyrics_search_matching_test.go @@ -42,10 +42,11 @@ func TestLyricsSearchSelectorsRejectUnrelatedSongWithMatchingArtistAndDuration(t if best := selectBestKugouLyricsSearchResult( []kugouLyricsSearchResult{{ - Hash: "azul", - Title: "Azul", - Artist: artistName, - Duration: durationSec, + ID: "azul", + AccessKey: "key", + Title: "Azul", + Artist: artistName, + Duration: durationSec * 1000, }}, trackName, artistName, diff --git a/go_backend/lyrics_supplement_test.go b/go_backend/lyrics_supplement_test.go index 221edbb4..f112f4f3 100644 --- a/go_backend/lyrics_supplement_test.go +++ b/go_backend/lyrics_supplement_test.go @@ -1,6 +1,7 @@ package gobackend import ( + "encoding/base64" "errors" "fmt" "io" @@ -494,22 +495,20 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { } qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("unexpected QQ method %s", req.Method) + switch req.URL.Path { + case "/soso/fcgi-bin/client_search_cp": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"song":{"list":[{"mid":"qq-mid","id":123,"name":"Song","interval":180,"singer":[{"name":"Artist"}]}]}}}`)), Request: req}, nil + case "/lyric/fcgi-bin/fcg_query_lyric_new.fcg": + encoded := base64.StdEncoding.EncodeToString([]byte("[00:01.00]QQ Direct")) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"retcode":0,"code":0,"lyric":"` + encoded + `"}`)), Request: req}, nil + default: + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil } - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"QQ","part":false,"timestamp":1000}]}]}`)), Request: req}, nil })}} - qqRaw, err := qq.fetchLyricsByMetadata("Song", "Artist", 180) - if err != nil || !strings.Contains(qqRaw, "lyrics") { - t.Fatalf("qq raw = %q/%v", qqRaw, err) - } qqLyrics, err := qq.FetchLyrics("Song", "Artist", 180, false) - if err != nil || qqLyrics.Provider != "QQ Music" { + if err != nil || qqLyrics.Provider != "QQ Music" || qqLyrics.Source != "QQ Music Direct" || qqLyrics.SyncType != "LINE_SYNCED" { t.Fatalf("qq lyrics = %#v/%v", qqLyrics, err) } - if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil { - t.Fatal("expected empty QQ metadata error") - } spotify := &SpotifyLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch { @@ -551,16 +550,17 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { kugou := &KugouLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch { - case strings.Contains(req.URL.Path, "/kugou/search"): - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"hash":"kg-1","title":"Song","artist":"Artist","duration":180}]`)), Request: req}, nil - case strings.Contains(req.URL.Path, "/kugou/lyrics"): - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics_text":"[00:01.00]Kugou"}`)), Request: req}, nil + case req.URL.Path == "/search": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"status":200,"errcode":200,"candidates":[{"id":"kg-1","accesskey":"key","song":"Song","singer":"Artist","duration":180000}]}`)), Request: req}, nil + case req.URL.Path == "/download": + encoded := base64.StdEncoding.EncodeToString([]byte("[00:01.00]Kugou")) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"status":200,"error_code":0,"content":"` + encoded + `"}`)), Request: req}, nil default: return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil } })}} kugouLyrics, err := kugou.FetchLyrics("Song", "Artist", 180) - if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.SyncType != "LINE_SYNCED" { + if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.Source != "Kugou Direct" || kugouLyrics.SyncType != "LINE_SYNCED" { t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err) } @@ -571,17 +571,14 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { t.Fatalf("genius per_page = %q", got) } return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil - case strings.Contains(req.URL.Path, "/genius/lyrics"): - if got := req.URL.Query().Get("v"); got != "2" { - t.Fatalf("genius API version = %q", got) - } - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil + case req.URL.Host == "genius.com" && req.URL.Path == "/artist-song-lyrics": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`
Contributors
[00:01.00]Genius
Direct line
[00:02.00]Second section
`)), Request: req}, nil default: return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil } })}} geniusLyrics, err := genius.FetchLyrics("Song", "Artist", 180) - if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.SyncType != "UNSYNCED" { + if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.Source != "Genius Direct" || geniusLyrics.SyncType != "LINE_SYNCED" || !strings.Contains(geniusLyrics.PlainLyrics, "Second section") { t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err) } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1af7c7b4..34b643bc 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3891,7 +3891,7 @@ "@lyricsProviderAppleMusicDesc": { "description": "Description for Apple Music provider" }, - "lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)", + "lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, direct API)", "@lyricsProviderQqMusicDesc": { "description": "Description for QQ Music provider" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c7d1ba8a..db10abe2 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5494,7 +5494,7 @@ "librarySourceLocal": "Local", "backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}", "sectionService": "Layanan", - "lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)", + "lyricsProviderQqMusicDesc": "QQ Music (cocok untuk lagu berbahasa Mandarin, API langsung)", "@lyricsProviderQqMusicDesc": { "description": "Description for QQ Music provider" },