feat: add 5 new lyrics providers

New lyrics providers using Paxsenix API:
- Spotify: Synced lyrics from Spotify
- Deezer: Synced lyrics from Deezer
- YouTube: Lyrics from YouTube
- Kugou: Lyrics from Kugou (Chinese service)
- Genius: Plain text lyrics from Genius

Implementation:
- Add lyrics client implementations for all providers
- Smart search result scoring based on track name, artist, and duration
- Support for both synced (LRC) and unsynced lyrics formats
- Fallback search with simplified track names and primary artist

UI updates:
- Add provider entries to lyrics priority settings page
- Add display names for new providers in settings
This commit is contained in:
zarzet
2026-05-14 18:43:26 +07:00
parent 3e3e87e73e
commit 4336e6dc78
39 changed files with 778 additions and 42 deletions
+66 -4
View File
@@ -26,6 +26,11 @@ const (
LyricsProviderMusixmatch = "musixmatch"
LyricsProviderAppleMusic = "apple_music"
LyricsProviderQQMusic = "qqmusic"
LyricsProviderSpotify = "spotify"
LyricsProviderDeezer = "deezer"
LyricsProviderYouTube = "youtube"
LyricsProviderKugou = "kugou"
LyricsProviderGenius = "genius"
)
var DefaultLyricsProviders = []string{
@@ -102,6 +107,11 @@ func SetLyricsProviderOrder(providers []string) {
LyricsProviderMusixmatch: true,
LyricsProviderAppleMusic: true,
LyricsProviderQQMusic: true,
LyricsProviderSpotify: true,
LyricsProviderDeezer: true,
LyricsProviderYouTube: true,
LyricsProviderKugou: true,
LyricsProviderGenius: true,
}
var valid []string
@@ -132,10 +142,15 @@ func GetLyricsProviderOrder() []string {
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
{"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": 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"},
}
}
@@ -550,6 +565,53 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
}
case LyricsProviderSpotify:
spotifyClient := NewSpotifyLyricsClient()
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderDeezer:
deezerClient := NewDeezerLyricsClient()
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
}
case LyricsProviderYouTube:
youtubeClient := NewYouTubeLyricsClient()
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderKugou:
kugouClient := NewKugouLyricsClient()
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
case LyricsProviderGenius:
geniusClient := NewGeniusLyricsClient()
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
if err != nil && primaryArtist != artistName {
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
}
if err != nil && simplifiedTrack != trackName {
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
}
default:
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
continue
+565
View File
@@ -0,0 +1,565 @@
package gobackend
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
type SpotifyLyricsClient struct {
httpClient *http.Client
}
type DeezerLyricsClient struct {
httpClient *http.Client
}
type YouTubeLyricsClient struct {
httpClient *http.Client
}
type KugouLyricsClient struct {
httpClient *http.Client
}
type GeniusLyricsClient struct {
httpClient *http.Client
}
type spotifyLyricsSearchResult struct {
TrackID string `json:"trackId"`
Name string `json:"name"`
ArtistName string `json:"artistName"`
Duration string `json:"duration"`
}
type youtubeLyricsSearchResult struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
Author string `json:"author"`
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 {
Hits []struct {
Type string `json:"type"`
Result struct {
Title string `json:"title"`
ArtistNames string `json:"artist_names"`
PrimaryArtistNames string `json:"primary_artist_names"`
URL string `json:"url"`
} `json:"result"`
} `json:"hits"`
} `json:"sections"`
} `json:"response"`
}
type paxsenixLyricsObject struct {
Type string `json:"type"`
Content []paxLyrics `json:"content"`
Lyrics []paxLyrics `json:"lyrics"`
LyricsText string `json:"lyrics_text"`
PlainLyrics string `json:"plain_lyrics"`
}
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
func NewDeezerLyricsClient() *DeezerLyricsClient {
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
}
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)}
}
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
fullURL := endpoint
if len(params) > 0 {
fullURL += "?" + params.Encode()
}
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return "", 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 "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
trimmed := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed == "" {
return "", fmt.Errorf("empty response")
}
return trimmed, nil
}
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return nil, fmt.Errorf("%s returned empty lyrics", provider)
}
return lyricsResponseFromText(lrcPayload, provider), nil
}
var rawObject map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
var value string
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
value = strings.TrimSpace(value)
if value != "" {
return lyricsResponseFromText(value, provider), nil
}
}
}
}
var payload paxsenixLyricsObject
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
switch {
case strings.TrimSpace(payload.LyricsText) != "":
return lyricsResponseFromText(payload.LyricsText, provider), nil
case len(payload.Lyrics) > 0:
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
case len(payload.Content) > 0:
lyricsType := payload.Type
if lyricsType == "" {
lyricsType = "Syllable"
}
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
case strings.TrimSpace(payload.PlainLyrics) != "":
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
}
}
trimmed := strings.TrimSpace(raw)
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
return lyricsResponseFromText(trimmed, provider), nil
}
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
}
func lyricsResponseFromText(text, provider string) *LyricsResponse {
lines := parseSyncedLyrics(text)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: provider,
Source: provider,
}
}
plainLines := plainTextLyricsLines(text)
if len(plainLines) > 0 {
return &LyricsResponse{
Lines: plainLines,
SyncType: "UNSYNCED",
PlainLyrics: text,
Provider: provider,
Source: provider,
}
}
return &LyricsResponse{Provider: provider, Source: provider}
}
func normalizeSpotifyLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
parts := strings.Split(raw, ":")
raw = parts[len(parts)-1]
}
if strings.Contains(raw, "spotify.com/track/") {
raw = extractSpotifyIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if regexpSpotifyTrackID.MatchString(raw) {
return raw
}
return ""
}
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
if err != nil {
return "", fmt.Errorf("spotify search failed: %w", err)
}
var results []spotifyLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode spotify search: %w", err)
}
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.TrackID) == "" {
return "", fmt.Errorf("no songs found on spotify")
}
return strings.TrimSpace(best.TrackID), nil
}
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
if err != nil {
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
}
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
trackID := normalizeSpotifyLyricsID(spotifyID)
if trackID == "" {
var err error
trackID, err = c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
}
return c.FetchLyricsByID(trackID)
}
func normalizeDeezerLyricsID(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
raw = strings.TrimSpace(raw[len("deezer:"):])
}
if strings.Contains(raw, "deezer.com/") {
raw = extractDeezerIDFromURL(raw)
}
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
return raw
}
return ""
}
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
params := url.Values{}
params.Set("id", trackID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
if err != nil {
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
}
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
deezerID := normalizeDeezerLyricsID(spotifyID)
if deezerID == "" {
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
if spotifyTrackID == "" {
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
}
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
if err != nil {
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
}
deezerID = normalizeDeezerLyricsID(resolvedID)
}
if deezerID == "" {
return nil, fmt.Errorf("deezer id unavailable")
}
return c.FetchLyricsByID(deezerID, true)
}
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
if err != nil {
return "", fmt.Errorf("youtube search failed: %w", err)
}
var results []youtubeLyricsSearchResult
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode youtube search: %w", err)
}
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.VideoID) == "" {
return "", fmt.Errorf("no songs found on youtube")
}
return strings.TrimSpace(best.VideoID), nil
}
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
videoID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("id", videoID)
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
if err != nil {
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
}
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
}
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := strings.TrimSpace(trackName + " " + artistName)
if query == "" {
return "", fmt.Errorf("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 "", fmt.Errorf("no songs found on kugou")
}
return strings.TrimSpace(best.Hash), nil
}
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
if len(results) == 0 {
return nil
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
if score > bestScore {
bestIndex = i
bestScore = score
}
}
return &results[bestIndex]
}
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 == "" {
return "", fmt.Errorf("empty search query")
}
params := url.Values{}
params.Set("q", query)
params.Set("per_page", "10")
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
if err != nil {
return "", fmt.Errorf("genius search failed: %w", err)
}
var results geniusSearchResponse
if err := json.Unmarshal([]byte(raw), &results); err != nil {
return "", fmt.Errorf("failed to decode genius search: %w", err)
}
bestURL := ""
bestScore := -1
for _, section := range results.Response.Sections {
for _, hit := range section.Hits {
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
continue
}
artist := hit.Result.PrimaryArtistNames
if strings.TrimSpace(artist) == "" {
artist = hit.Result.ArtistNames
}
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
if score > bestScore {
bestScore = score
bestURL = strings.TrimSpace(hit.Result.URL)
}
}
}
if bestURL == "" {
return "", fmt.Errorf("no songs found on genius")
}
return bestURL, nil
}
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("url", geniusURL)
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)
}
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
score := 0
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && candidateDuration > 0 {
diff := math.Abs(candidateDuration - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
return score
}
func parseClockDuration(value string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parts := strings.Split(value, ":")
total := 0
for _, part := range parts {
n, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return 0
}
total = total*60 + n
}
return float64(total)
}
+68
View File
@@ -247,4 +247,72 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
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 {
case strings.Contains(req.URL.Path, "/spotify/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"trackId":"spotify-1","name":"Song","artistName":"Artist","duration":"03:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/spotify/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Spotify"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
spotifyLyrics, err := spotify.FetchLyrics("", "Song", "Artist", 180)
if err != nil || spotifyLyrics.Provider != "Spotify" || spotifyLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("spotify lyrics = %#v/%v", spotifyLyrics, err)
}
deezer := &DeezerLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"Deezer","part":false}]}]}`)), Request: req}, nil
})}}
deezerLyrics, err := deezer.FetchLyricsByID("123", false)
if err != nil || deezerLyrics.Provider != "Deezer" || deezerLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("deezer lyrics = %#v/%v", deezerLyrics, err)
}
youtube := &YouTubeLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/youtube/search"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"videoId":"yt-1","title":"Song","author":"Artist","duration":"3:00"}]`)), Request: req}, nil
case strings.Contains(req.URL.Path, "/youtube/lyrics"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]YouTube"`)), Request: req}, nil
default:
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
}
})}}
youtubeLyrics, err := youtube.FetchLyrics("Song", "Artist", 180)
if err != nil || youtubeLyrics.Provider != "YouTube" || youtubeLyrics.SyncType != "LINE_SYNCED" {
t.Fatalf("youtube lyrics = %#v/%v", youtubeLyrics, err)
}
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
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" {
t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err)
}
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/api/search/multi"):
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"):
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), 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" {
t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err)
}
}
+1 -1
View File
@@ -837,7 +837,7 @@ abstract class AppLocalizations {
/// App description in header card
///
/// In en, this message translates to:
/// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'**
/// **'Search music metadata, manage extensions, and organize your library.'**
String get aboutAppDescription;
/// Section header for artist albums
+1 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Alben';
+1 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
+2 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -4585,7 +4585,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutAppDescription =>
'Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbumes';
+1 -1
View File
@@ -399,7 +399,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
+1 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
+1 -1
View File
@@ -400,7 +400,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutAppDescription =>
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Album';
+1 -1
View File
@@ -393,7 +393,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'アルバム';
+1 -1
View File
@@ -385,7 +385,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => '앨범';
+1 -1
View File
@@ -397,7 +397,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
+2 -2
View File
@@ -397,7 +397,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -4584,7 +4584,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Álbuns';
+1 -1
View File
@@ -403,7 +403,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбомы';
+1 -1
View File
@@ -405,7 +405,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutAppDescription =>
'Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albümler';
+1 -1
View File
@@ -407,7 +407,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get aboutAppDescription =>
'Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Альбоми';
+3 -3
View File
@@ -397,7 +397,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -4565,7 +4565,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
@@ -8046,7 +8046,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
'Search music metadata, manage extensions, and organize your library.';
@override
String get artistAlbums => 'Albums';
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Descargar pistas de Spotify en alta calidad (sin pérdida) de Tidal y Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -458,7 +458,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -438,7 +438,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Spotify parçalarını Tidal ve Qobuz aracılığıyla kayıpsız kalitede indirin.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -398,7 +398,7 @@
"@aboutSachinsenalDesc": {
"description": "Credit description for sachinsenal0x64"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+1 -1
View File
@@ -498,7 +498,7 @@
"@aboutSjdonadoDesc": {
"description": "Credit description for sjdonado"
},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
},
+2 -1
View File
@@ -141,7 +141,8 @@ class AboutPage extends StatelessWidget {
icon: Icons.lyrics_outlined,
title: 'Paxsenix',
subtitle:
'Partner lyrics proxy for Apple Music and QQ Music sources',
'Lyrics proxy for Musixmatch, Netease, Apple Music, '
'QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius',
onTap: () => _launchUrl('https://lyrics.paxsenix.org'),
showDivider: false,
),
@@ -21,6 +21,11 @@ class _LyricsProviderPriorityPageState
'musixmatch',
'apple_music',
'qqmusic',
'spotify',
'deezer',
'youtube',
'kugou',
'genius',
];
late List<String> _enabledProviders;
@@ -211,6 +216,36 @@ class _LyricsProviderPriorityPageState
description: context.l10n.lyricsProviderQqMusicDesc,
icon: Icons.queue_music,
);
case 'spotify':
return _LyricsProviderInfo(
name: 'Spotify',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.graphic_eq,
);
case 'deezer':
return _LyricsProviderInfo(
name: 'Deezer',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.album_outlined,
);
case 'youtube':
return _LyricsProviderInfo(
name: 'YouTube',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.smart_display_outlined,
);
case 'kugou':
return _LyricsProviderInfo(
name: 'Kugou',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.library_music_outlined,
);
case 'genius':
return _LyricsProviderInfo(
name: 'Genius',
description: context.l10n.lyricsProviderExtensionDesc,
icon: Icons.auto_awesome_outlined,
);
default:
return _LyricsProviderInfo(
name: id,
@@ -216,6 +216,11 @@ class LyricsSettingsPage extends ConsumerWidget {
'musixmatch': 'Musixmatch',
'apple_music': 'Apple Music',
'qqmusic': 'QQ Music',
'spotify': 'Spotify',
'deezer': 'Deezer',
'youtube': 'YouTube',
'kugou': 'Kugou',
'genius': 'Genius',
};
String _getLyricsProvidersSubtitle(