mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
feat(lyrics): use direct Kugou, QQ Music, and Genius sources
This commit is contained in:
@@ -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)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user