mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
228 lines
5.8 KiB
Go
228 lines
5.8 KiB
Go
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
|
|
}
|