feat(lyrics): use direct Kugou, QQ Music, and Genius sources

This commit is contained in:
zarzet
2026-08-31 13:39:18 +07:00
parent a1ee346f2f
commit 01fc7ee6c2
10 changed files with 535 additions and 195 deletions
+3 -3
View File
@@ -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)"},
}
}
+123
View File
@@ -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
}
+227
View File
@@ -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
}
+3
View File
@@ -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
+1 -80
View File
@@ -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 {
+152 -84
View File
@@ -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
}
+5 -4
View File
@@ -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,
+19 -22
View File
@@ -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(`<html><div data-lyrics-container="true"><div data-exclude-from-selection="true">Contributors</div>[00:01.00]Genius<br/>Direct line</div><div data-lyrics-container="true">[00:02.00]Second section</div></html>`)), 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)
}
}
+1 -1
View File
@@ -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"
},
+1 -1
View File
@@ -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"
},