From 30973a8e78683e8a57db80a7ee61f6310428d76d Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 14 Feb 2026 01:42:18 +0700 Subject: [PATCH] feat: lyrics provider extensions, configurable lyrics cascade, and iOS method channel parity Add lyrics_provider as a new extension type alongside metadata_provider and download_provider. Extensions implementing fetchLyrics() are called before built-in providers, giving user-installed extensions highest priority. Built-in lyrics cascade is now configurable from Download Settings: - Reorderable provider list (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music) - Per-provider options: Netease translation/romanization, Apple/QQ multi-person word-by-word speaker tags, Musixmatch language code - Provider order and options synced to Go backend on app start and on change Go backend changes: - New lyrics_provider manifest type with validation (extension_manifest.go) - ExtensionProviderWrapper.FetchLyrics() with Goja JS bridge (extension_providers.go) - Configurable SetLyricsProviderOrder/GetLyricsProviderOrder cascade (lyrics.go) - LyricsFetchOptions struct for per-provider settings (lyrics.go) - Extracted tryLRCLIB() helper, randomized LRCLIB User-Agent (lyrics.go) - Refactored msToLRCTimestamp to separate msToLRCTimestampInline (lyrics.go) - New provider source files: lyrics_apple.go, lyrics_musixmatch.go, lyrics_netease.go, lyrics_qqmusic.go - JSON export functions for lyrics settings (exports.go) - hasLyricsProvider field in extension manager JSON output Platform channels: - Android (MainActivity.kt): setLyricsProviders, getLyricsProviders, getAvailableLyricsProviders, setLyricsFetchOptions, getLyricsFetchOptions - iOS (AppDelegate.swift): same 5 method channel handlers for iOS parity Flutter side: - Extension model: hasLyricsProvider field + Lyrics Provider capability badge - Settings model: lyricsProviders, lyricsIncludeTranslationNetease, lyricsIncludeRomanizationNetease, lyricsMultiPersonWordByWord, musixmatchLanguage fields with generated serialization - Settings provider: setters + _syncLyricsSettingsToBackend() - Download settings UI: provider picker, toggle switches, language picker - Platform bridge: lyrics provider/options methods Docs: lyrics provider extension documentation in site/docs.html CHANGELOG: updated with lyrics provider and search feature entries --- CHANGELOG.md | 14 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 54 +++ go_backend/exports.go | 56 +++ go_backend/extension_manager.go | 2 + go_backend/extension_manifest.go | 9 +- go_backend/extension_providers.go | 138 +++++++ go_backend/lyrics.go | 357 +++++++++++++++-- go_backend/lyrics_apple.go | 378 ++++++++++++++++++ go_backend/lyrics_musixmatch.go | 214 ++++++++++ go_backend/lyrics_netease.go | 209 ++++++++++ go_backend/lyrics_qqmusic.go | 208 ++++++++++ ios/Runner/AppDelegate.swift | 30 ++ lib/models/settings.dart | 33 ++ lib/models/settings.g.dart | 108 ++--- lib/providers/extension_provider.dart | 5 + lib/providers/settings_provider.dart | 47 +++ .../settings/download_settings_page.dart | 327 +++++++++++++++ .../settings/extension_detail_page.dart | 5 + lib/services/platform_bridge.dart | 38 ++ site/docs.html | 344 +++++++++++++++- 20 files changed, 2484 insertions(+), 92 deletions(-) create mode 100644 go_backend/lyrics_apple.go create mode 100644 go_backend/lyrics_musixmatch.go create mode 100644 go_backend/lyrics_netease.go create mode 100644 go_backend/lyrics_qqmusic.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8273df1d..e4a60089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ - Project website with GitHub Pages deployment workflow - Mobile burger menu navigation for all site pages - Go filename template test suite +- "Lyrics Provider" extension type - extensions can now provide lyrics (synced or plain text) via `fetchLyrics()` function + - Lyrics provider extensions are called before built-in providers, giving extensions highest priority + - New `lyrics_provider` manifest type alongside `metadata_provider` and `download_provider` + - Shows "Lyrics Provider" capability badge on extension detail page +- "Lyrics Providers" settings - configurable provider cascade order and per-provider options + - Reorderable provider list: LRCLIB, Musixmatch, Netease, Apple Music, QQ Music + - Netease: toggle translated/romanized lyrics appending + - Apple Music / QQ Music: multi-person word-by-word speaker tags + - Musixmatch: selectable language code for localized lyrics +- "Documentation Search" - global search modal on all site pages + - Opens with Ctrl+K / Cmd+K / `/` keyboard shortcuts on every page + - Search button with bordered pill styling in desktop nav and mobile hamburger menu + - On non-docs pages, search results navigate to the docs page at the matching section + - Full keyboard navigation: arrow keys, Enter to select, Esc to close ### Fixed diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 931891e9..5e8d2c67 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1756,6 +1756,60 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "setLyricsProviders" -> { + val providersJson = call.argument("providers_json") ?: "[]" + val response = withContext(Dispatchers.IO) { + try { + Gobackend.setLyricsProvidersJSON(providersJson) + """{"success":true}""" + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "getLyricsProviders" -> { + val response = withContext(Dispatchers.IO) { + try { + Gobackend.getLyricsProvidersJSON() + } catch (e: Exception) { + "[]" + } + } + result.success(response) + } + "getAvailableLyricsProviders" -> { + val response = withContext(Dispatchers.IO) { + try { + Gobackend.getAvailableLyricsProvidersJSON() + } catch (e: Exception) { + "[]" + } + } + result.success(response) + } + "setLyricsFetchOptions" -> { + val optionsJson = call.argument("options_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + Gobackend.setLyricsFetchOptionsJSON(optionsJson) + """{"success":true}""" + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "getLyricsFetchOptions" -> { + val response = withContext(Dispatchers.IO) { + try { + Gobackend.getLyricsFetchOptionsJSON() + } catch (e: Exception) { + "{}" + } + } + result.success(response) + } "reEnrichFile" -> { val requestJson = call.argument("request_json") ?: "{}" val response = withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 7020cfdc..88f09092 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1599,6 +1599,62 @@ func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int6 return nil } +// ==================== LYRICS PROVIDER SETTINGS ==================== + +// SetLyricsProvidersJSON sets the lyrics provider order from a JSON array of provider IDs. +func SetLyricsProvidersJSON(providersJSON string) error { + var providers []string + if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { + return err + } + + SetLyricsProviderOrder(providers) + return nil +} + +// GetLyricsProvidersJSON returns the current lyrics provider order as JSON. +func GetLyricsProvidersJSON() (string, error) { + providers := GetLyricsProviderOrder() + jsonBytes, err := json.Marshal(providers) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// GetAvailableLyricsProvidersJSON returns metadata about all available lyrics providers. +func GetAvailableLyricsProvidersJSON() (string, error) { + providers := GetAvailableLyricsProviders() + jsonBytes, err := json.Marshal(providers) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// SetLyricsFetchOptionsJSON sets lyrics provider fetch options. +func SetLyricsFetchOptionsJSON(optionsJSON string) error { + opts := GetLyricsFetchOptions() + if strings.TrimSpace(optionsJSON) != "" { + if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil { + return err + } + } + + SetLyricsFetchOptions(opts) + return nil +} + +// GetLyricsFetchOptionsJSON returns current lyrics provider fetch options. +func GetLyricsFetchOptionsJSON() (string, error) { + opts := GetLyricsFetchOptions() + jsonBytes, err := json.Marshal(opts) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + // ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file. // When search_online is true, searches Spotify/Deezer by track name + artist to fetch // complete metadata from the internet before embedding. diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index a2239fbf..1b612691 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -713,6 +713,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { Permissions []string `json:"permissions"` HasMetadataProvider bool `json:"has_metadata_provider"` HasDownloadProvider bool `json:"has_download_provider"` + HasLyricsProvider bool `json:"has_lyrics_provider"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` @@ -770,6 +771,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { Permissions: permissions, HasMetadataProvider: ext.Manifest.IsMetadataProvider(), HasDownloadProvider: ext.Manifest.IsDownloadProvider(), + HasLyricsProvider: ext.Manifest.IsLyricsProvider(), SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, SearchBehavior: ext.Manifest.SearchBehavior, TrackMatching: ext.Manifest.TrackMatching, diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 9fc8f3da..f4164b4f 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -12,6 +12,7 @@ type ExtensionType string const ( ExtensionTypeMetadataProvider ExtensionType = "metadata_provider" ExtensionTypeDownloadProvider ExtensionType = "download_provider" + ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider" ) type SettingType string @@ -167,10 +168,10 @@ func (m *ExtensionManifest) Validate() error { } for _, t := range m.Types { - if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { + if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider { return &ManifestValidationError{ Field: "type", - Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t), + Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t), } } } @@ -226,6 +227,10 @@ func (m *ExtensionManifest) IsDownloadProvider() bool { return m.HasType(ExtensionTypeDownloadProvider) } +func (m *ExtensionManifest) IsLyricsProvider() bool { + return m.HasType(ExtensionTypeLyricsProvider) +} + func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { domain = strings.ToLower(strings.TrimSpace(domain)) for _, allowed := range m.Permissions.Network { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index a83a704b..daf09111 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "path/filepath" + "sort" "strings" "sync" "time" @@ -1699,3 +1700,140 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil } + +// ==================== Lyrics Provider ==================== + +// ExtLyricsResult represents lyrics data returned from an extension +type ExtLyricsResult struct { + Lines []ExtLyricsLine `json:"lines"` + SyncType string `json:"syncType"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + Provider string `json:"provider"` +} + +type ExtLyricsLine struct { + StartTimeMs int64 `json:"startTimeMs"` + Words string `json:"words"` + EndTimeMs int64 `json:"endTimeMs"` +} + +// FetchLyrics calls the extension's fetchLyrics function +func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { + if !p.extension.Manifest.IsLyricsProvider() { + return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + + // Use global variables to avoid JS injection issues with special characters in track/artist names + const trackVar = "__sf_lyrics_track" + const artistVar = "__sf_lyrics_artist" + const albumVar = "__sf_lyrics_album" + const durationVar = "__sf_lyrics_duration" + global := p.vm.GlobalObject() + _ = global.Set(trackVar, trackName) + _ = global.Set(artistVar, artistName) + _ = global.Set(albumVar, albumName) + _ = global.Set(durationVar, durationSec) + defer func() { + global.Delete(trackVar) + global.Delete(artistVar) + global.Delete(albumVar) + global.Delete(durationVar) + }() + + const script = ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') { + return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration); + } + return null; + })() + ` + + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond") + } + return nil, fmt.Errorf("fetchLyrics failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("fetchLyrics returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal lyrics result: %w", err) + } + + var extResult ExtLyricsResult + if err := json.Unmarshal(jsonBytes, &extResult); err != nil { + return nil, fmt.Errorf("failed to parse lyrics result: %w", err) + } + + // Convert ExtLyricsResult to LyricsResponse + response := &LyricsResponse{ + SyncType: extResult.SyncType, + Instrumental: extResult.Instrumental, + PlainLyrics: extResult.PlainLyrics, + Provider: extResult.Provider, + Source: "Extension: " + p.extension.ID, + } + + if response.Provider == "" { + response.Provider = p.extension.Manifest.DisplayName + } + + for _, line := range extResult.Lines { + response.Lines = append(response.Lines, LyricsLine{ + StartTimeMs: line.StartTimeMs, + Words: line.Words, + EndTimeMs: line.EndTimeMs, + }) + } + + // If the extension provided plainLyrics but no lines, parse them as unsynced + if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { + response.SyncType = "UNSYNCED" + for _, line := range strings.Split(response.PlainLyrics, "\n") { + if strings.TrimSpace(line) != "" { + response.Lines = append(response.Lines, LyricsLine{ + StartTimeMs: 0, + Words: line, + EndTimeMs: 0, + }) + } + } + } + + return response, nil +} + +// GetLyricsProviders returns all enabled extensions that provide lyrics +func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + + // Keep a deterministic order so provider selection is stable across runs. + sort.Slice(providers, func(i, j int) bool { + return providers[i].extension.ID < providers[j].extension.ID + }) + + return providers +} diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 723dadf5..3fe5c3ee 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -20,6 +20,140 @@ const ( durationToleranceSec = 10.0 ) +// Lyrics provider names (used in settings and cascade ordering) +const ( + LyricsProviderLRCLIB = "lrclib" + LyricsProviderNetease = "netease" + LyricsProviderMusixmatch = "musixmatch" + LyricsProviderAppleMusic = "apple_music" + LyricsProviderQQMusic = "qqmusic" +) + +// DefaultLyricsProviders is the default cascade order for lyrics fetching. +// LRCLIB first (no proxy dependency), then the others. +var DefaultLyricsProviders = []string{ + LyricsProviderLRCLIB, + LyricsProviderMusixmatch, + LyricsProviderNetease, + LyricsProviderAppleMusic, + LyricsProviderQQMusic, +} + +// Global lyrics provider configuration +var ( + lyricsProvidersMu sync.RWMutex + lyricsProviders []string // ordered list of enabled providers +) + +// LyricsFetchOptions controls optional provider-specific enhancements. +type LyricsFetchOptions struct { + IncludeTranslationNetease bool `json:"include_translation_netease"` + IncludeRomanizationNetease bool `json:"include_romanization_netease"` + MultiPersonWordByWord bool `json:"multi_person_word_by_word"` + MusixmatchLanguage string `json:"musixmatch_language,omitempty"` +} + +var defaultLyricsFetchOptions = LyricsFetchOptions{ + IncludeTranslationNetease: false, + IncludeRomanizationNetease: false, + MultiPersonWordByWord: true, + MusixmatchLanguage: "", +} + +var ( + lyricsFetchOptionsMu sync.RWMutex + lyricsFetchOptions = defaultLyricsFetchOptions +) + +// SetLyricsProviderOrder sets the ordered list of lyrics providers to try. +// Providers not in the list are disabled. An empty list resets to defaults. +func SetLyricsProviderOrder(providers []string) { + lyricsProvidersMu.Lock() + defer lyricsProvidersMu.Unlock() + + if len(providers) == 0 { + lyricsProviders = nil + return + } + + // Validate provider names + validNames := map[string]bool{ + LyricsProviderLRCLIB: true, + LyricsProviderNetease: true, + LyricsProviderMusixmatch: true, + LyricsProviderAppleMusic: true, + LyricsProviderQQMusic: true, + } + + var valid []string + for _, p := range providers { + normalized := strings.ToLower(strings.TrimSpace(p)) + if validNames[normalized] { + valid = append(valid, normalized) + } + } + + lyricsProviders = valid + GoLog("[Lyrics] Provider order set to: %v\n", valid) +} + +// GetLyricsProviderOrder returns the current lyrics provider order. +func GetLyricsProviderOrder() []string { + lyricsProvidersMu.RLock() + defer lyricsProvidersMu.RUnlock() + + if len(lyricsProviders) == 0 { + return DefaultLyricsProviders + } + + result := make([]string, len(lyricsProviders)) + copy(result, lyricsProviders) + return result +} + +// GetAvailableLyricsProviders returns metadata about all available providers. +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": false, "description": "NetEase Cloud Music (good for Asian songs)"}, + {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, + {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"}, + {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"}, + } +} + +func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { + opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage)) + opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "") + if len(opts.MusixmatchLanguage) > 16 { + opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16] + } + return opts +} + +// SetLyricsFetchOptions sets provider-specific lyric fetch behavior. +func SetLyricsFetchOptions(opts LyricsFetchOptions) { + normalized := normalizeLyricsFetchOptions(opts) + + lyricsFetchOptionsMu.Lock() + defer lyricsFetchOptionsMu.Unlock() + lyricsFetchOptions = normalized + + GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n", + normalized.IncludeTranslationNetease, + normalized.IncludeRomanizationNetease, + normalized.MultiPersonWordByWord, + normalized.MusixmatchLanguage, + ) +} + +// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior. +func GetLyricsFetchOptions() LyricsFetchOptions { + lyricsFetchOptionsMu.RLock() + defer lyricsFetchOptionsMu.RUnlock() + return lyricsFetchOptions +} + type lyricsCacheEntry struct { response *LyricsResponse expiresAt time.Time @@ -139,7 +273,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0") + req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { @@ -174,7 +308,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0") + req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := c.httpClient.Do(req) if err != nil { @@ -240,68 +374,203 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) { primaryArtist := normalizeArtistName(artistName) + fetchOptions := GetLyricsFetchOptions() - if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { - fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) - cachedCopy := *cached - cachedCopy.Source = cached.Source + " (cached)" - return &cachedCopy, nil + extManager := GetExtensionManager() + var extensionProviders []*ExtensionProviderWrapper + if extManager != nil { + extensionProviders = extManager.GetLyricsProviders() } - var lyrics *LyricsResponse - var err error + var cachedNonExtension *LyricsResponse + if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found { + isExtensionCache := strings.HasPrefix(cached.Source, "Extension:") + if len(extensionProviders) == 0 || isExtensionCache { + fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) + cachedCopy := *cached + cachedCopy.Source = cached.Source + " (cached)" + return &cachedCopy, nil + } + + // If extension providers are currently enabled, don't let stale built-in cache + // mask newly installed/activated extensions. + cachedNonExtension = cached + GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n") + } isValidResult := func(l *LyricsResponse) bool { return l != nil && (len(l.Lines) > 0 || l.Instrumental) } - lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName) - if err == nil && isValidResult(lyrics) { - lyrics.Source = "LRCLIB" - globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) - return lyrics, nil - } - - if primaryArtist != artistName { - lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) - if err == nil && isValidResult(lyrics) { - lyrics.Source = "LRCLIB" - globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) - return lyrics, nil + // Try extension lyrics providers first + if len(extensionProviders) > 0 { + for _, provider := range extensionProviders { + GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID) + lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec) + if err == nil && isValidResult(lyrics) { + GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID) + globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) + return lyrics, nil + } + if err != nil { + GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err) + } } } + if cachedNonExtension != nil { + cachedCopy := *cachedNonExtension + cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)" + GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n") + return &cachedCopy, nil + } + + // Get configured provider order + providerOrder := GetLyricsProviderOrder() simplifiedTrack := simplifyTrackName(trackName) - if simplifiedTrack != trackName { - lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack) + + GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder) + + // Cascade through all configured built-in providers + for _, providerName := range providerOrder { + GoLog("[Lyrics] Trying provider: %s\n", providerName) + + var lyrics *LyricsResponse + var err error + + switch providerName { + case LyricsProviderLRCLIB: + lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) + + case LyricsProviderNetease: + neteaseClient := NewNeteaseClient() + lyrics, err = neteaseClient.FetchLyrics( + trackName, + primaryArtist, + durationSec, + fetchOptions.IncludeTranslationNetease, + fetchOptions.IncludeRomanizationNetease, + ) + if err != nil && primaryArtist != artistName { + lyrics, err = neteaseClient.FetchLyrics( + trackName, + artistName, + durationSec, + fetchOptions.IncludeTranslationNetease, + fetchOptions.IncludeRomanizationNetease, + ) + } + if err != nil && simplifiedTrack != trackName { + lyrics, err = neteaseClient.FetchLyrics( + simplifiedTrack, + primaryArtist, + durationSec, + fetchOptions.IncludeTranslationNetease, + fetchOptions.IncludeRomanizationNetease, + ) + } + + case LyricsProviderMusixmatch: + musixmatchClient := NewMusixmatchClient() + lyrics, err = musixmatchClient.FetchLyrics( + trackName, + primaryArtist, + durationSec, + fetchOptions.MusixmatchLanguage, + ) + if err != nil && primaryArtist != artistName { + lyrics, err = musixmatchClient.FetchLyrics( + trackName, + artistName, + durationSec, + fetchOptions.MusixmatchLanguage, + ) + } + + case LyricsProviderAppleMusic: + appleClient := NewAppleMusicClient() + lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord) + if err != nil && primaryArtist != artistName { + lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord) + } + + case LyricsProviderQQMusic: + qqClient := NewQQMusicClient() + lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord) + if err != nil && primaryArtist != artistName { + lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord) + } + + default: + GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName) + continue + } + if err == nil && isValidResult(lyrics) { - lyrics.Source = "LRCLIB (simplified)" + GoLog("[Lyrics] Got lyrics from: %s\n", providerName) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil } - } - query := primaryArtist + " " + trackName - lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) - if err == nil && isValidResult(lyrics) { - lyrics.Source = "LRCLIB Search" - globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) - return lyrics, nil - } - - if simplifiedTrack != trackName { - query = primaryArtist + " " + simplifiedTrack - lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) - if err == nil && isValidResult(lyrics) { - lyrics.Source = "LRCLIB Search (simplified)" - globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) - return lyrics, nil + if err != nil { + GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err) } } return nil, fmt.Errorf("lyrics not found from any source") } +// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search). +func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) { + var lyrics *LyricsResponse + var err error + + // 1. Exact match with primary artist + lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName) + if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { + lyrics.Source = "LRCLIB" + return lyrics, nil + } + + // 2. Exact match with full artist name + if primaryArtist != artistName { + lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) + if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { + lyrics.Source = "LRCLIB" + return lyrics, nil + } + } + + // 3. Simplified track name + if simplifiedTrack != trackName { + lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack) + if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { + lyrics.Source = "LRCLIB (simplified)" + return lyrics, nil + } + } + + // 4. Search by query + query := primaryArtist + " " + trackName + lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) + if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { + lyrics.Source = "LRCLIB Search" + return lyrics, nil + } + + // 5. Search with simplified track name + if simplifiedTrack != trackName { + query = primaryArtist + " " + simplifiedTrack + lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec) + if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) { + lyrics.Source = "LRCLIB Search (simplified)" + return lyrics, nil + } + } + + return nil, fmt.Errorf("LRCLIB: no lyrics found") +} + func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse { result := &LyricsResponse{ Instrumental: resp.Instrumental, @@ -376,12 +645,16 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 { } func msToLRCTimestamp(ms int64) string { + return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms)) +} + +func msToLRCTimestampInline(ms int64) string { totalSeconds := ms / 1000 minutes := totalSeconds / 60 seconds := totalSeconds % 60 centiseconds := (ms % 1000) / 10 - return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) + return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds) } func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string { diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go new file mode 100644 index 00000000..7b95fa2f --- /dev/null +++ b/go_backend/lyrics_apple.go @@ -0,0 +1,378 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// AppleMusicClient fetches lyrics from Apple Music. +// Uses a scraped JWT token for search and a proxy for lyrics. +type AppleMusicClient struct { + httpClient *http.Client +} + +// Apple Music token manager — singleton with mutex for thread safety +type appleTokenManager struct { + mu sync.Mutex + token string +} + +var globalAppleTokenManager = &appleTokenManager{} + +func (m *appleTokenManager) getToken(client *http.Client) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.token != "" { + return m.token, nil + } + + // Step 1: Fetch the Apple Music beta page + req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch Apple Music page: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read Apple Music page: %w", err) + } + + // Step 2: Find the index JS file URL + indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`) + match := indexJsRegex.Find(body) + if match == nil { + return "", fmt.Errorf("could not find index JS script URL on Apple Music page") + } + + indexJsURL := "https://beta.music.apple.com" + string(match) + + // Step 3: Fetch the JS file + jsReq, err := http.NewRequest("GET", indexJsURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create JS request: %w", err) + } + jsReq.Header.Set("User-Agent", getRandomUserAgent()) + + jsResp, err := client.Do(jsReq) + if err != nil { + return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err) + } + defer jsResp.Body.Close() + + jsBody, err := io.ReadAll(jsResp.Body) + if err != nil { + return "", fmt.Errorf("failed to read Apple Music JS: %w", err) + } + + // Step 4: Extract JWT token (starts with eyJh) + tokenRegex := regexp.MustCompile(`eyJh[^"]*`) + tokenMatch := tokenRegex.Find(jsBody) + if tokenMatch == nil { + return "", fmt.Errorf("could not find JWT token in Apple Music JS") + } + + m.token = string(tokenMatch) + GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token)) + return m.token, nil +} + +func (m *appleTokenManager) clearToken() { + m.mu.Lock() + defer m.mu.Unlock() + m.token = "" +} + +// Apple Music API response models +type appleMusicSearchResponse struct { + Results struct { + Songs *struct { + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"data"` + } `json:"songs"` + } `json:"results"` + Resources *struct { + Songs map[string]struct { + Attributes struct { + Name string `json:"name"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + URL string `json:"url"` + Artwork struct { + URL string `json:"url"` + } `json:"artwork"` + } `json:"attributes"` + } `json:"songs"` + } `json:"resources"` +} + +// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics +type paxResponse struct { + Type string `json:"type"` // "Syllable" or "Line" + Content []paxLyrics `json:"content"` // List of lyric lines +} + +type paxLyrics struct { + Text []paxLyricDetail `json:"text"` + Timestamp int `json:"timestamp"` + OppositeTurn bool `json:"oppositeTurn"` + Background bool `json:"background"` + BackgroundText []paxLyricDetail `json:"backgroundText"` + EndTime int `json:"endtime"` +} + +type paxLyricDetail struct { + Text string `json:"text"` + Part bool `json:"part"` + Timestamp *int `json:"timestamp"` + EndTime *int `json:"endtime"` +} + +func NewAppleMusicClient() *AppleMusicClient { + return &AppleMusicClient{ + httpClient: NewMetadataHTTPClient(20 * time.Second), + } +} + +// SearchSong searches for a song on Apple Music and returns its ID. +func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) { + query := trackName + " " + artistName + if strings.TrimSpace(query) == "" { + return "", fmt.Errorf("empty search query") + } + + token, err := globalAppleTokenManager.getToken(c.httpClient) + if err != nil { + return "", fmt.Errorf("apple music token error: %w", err) + } + + encodedQuery := url.QueryEscape(query) + searchURL := fmt.Sprintf( + "https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl", + encodedQuery, + ) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Origin", "https://music.apple.com") + req.Header.Set("Referer", "https://music.apple.com/") + req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("apple music search failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + globalAppleTokenManager.clearToken() + return "", fmt.Errorf("apple music token expired") + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode) + } + + var searchResp appleMusicSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return "", fmt.Errorf("failed to decode apple music response: %w", err) + } + + if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 { + return "", fmt.Errorf("no songs found on apple music") + } + + return searchResp.Results.Songs.Data[0].ID, nil +} + +// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID. +func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { + lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID) + + req, err := http.NewRequest("GET", lyricsURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("apple music lyrics fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("apple music 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 apple music") + } + + return bodyStr, nil +} + +// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format. +func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) { + // Try to parse as PaxResponse first + var paxResp paxResponse + if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil { + return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil + } + + // Try to parse as a direct list of PaxLyrics + var directLyrics []paxLyrics + if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 { + return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil + } + + return "", fmt.Errorf("failed to parse pax lyrics response") +} + +func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) { + lastStart := "" + + for _, syllable := range details { + if syllable.Timestamp != nil { + start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp))) + if start != lastStart { + builder.WriteString(start) + lastStart = start + } + } + + builder.WriteString(syllable.Text) + if !syllable.Part { + builder.WriteString(" ") + } + + if syllable.EndTime != nil { + builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime)))) + } + } +} + +func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string { + var sb strings.Builder + + for i, line := range content { + if i > 0 { + sb.WriteString("\n") + } + + timestamp := msToLRCTimestamp(int64(line.Timestamp)) + + if lyricsType == "Syllable" { + sb.WriteString(timestamp) + if multiPersonWordByWord { + if line.OppositeTurn { + sb.WriteString("v2:") + } else { + sb.WriteString("v1:") + } + } + + appendPaxLyricDetail(&sb, line.Text) + + if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 { + sb.WriteString("\n[bg:") + appendPaxLyricDetail(&sb, line.BackgroundText) + sb.WriteString("]") + } + } else { + if len(line.Text) > 0 { + sb.WriteString(timestamp) + sb.WriteString(line.Text[0].Text) + } + } + } + + return strings.TrimSpace(sb.String()) +} + +// FetchLyrics searches Apple Music and returns parsed LyricsResponse. +func (c *AppleMusicClient) FetchLyrics( + trackName, + artistName string, + durationSec float64, + multiPersonWordByWord bool, +) (*LyricsResponse, error) { + songID, err := c.SearchSong(trackName, artistName) + if err != nil { + return nil, err + } + + rawLyrics, err := c.FetchLyricsByID(songID) + if err != nil { + return nil, err + } + + // Try to parse as pax format (word-by-word or line) + lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord) + if err != nil { + // If pax parsing fails, try to parse as direct LRC text + lrcText = rawLyrics + } + + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + Provider: "Apple Music", + Source: "Apple Music", + }, nil + } + + // Fall back to plain text if no timestamps found + plainLines := strings.Split(lrcText, "\n") + var resultLines []LyricsLine + for _, line := range plainLines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + resultLines = append(resultLines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + } + + if len(resultLines) > 0 { + return &LyricsResponse{ + Lines: resultLines, + SyncType: "UNSYNCED", + Provider: "Apple Music", + Source: "Apple Music", + }, nil + } + + return nil, fmt.Errorf("no lyrics found on apple music") +} diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go new file mode 100644 index 00000000..c3582b66 --- /dev/null +++ b/go_backend/lyrics_musixmatch.go @@ -0,0 +1,214 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// MusixmatchClient fetches lyrics from Musixmatch via a proxy server. +// The proxy handles Musixmatch authentication internally. +type MusixmatchClient struct { + httpClient *http.Client + baseURL string +} + +// Musixmatch proxy response models +type musixmatchSearchResponse struct { + ID int64 `json:"id"` + SongName string `json:"songName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Artwork string `json:"artwork"` + ReleaseDate string `json:"releaseDate"` + Duration int `json:"duration"` + URL string `json:"url"` + AlbumID int64 `json:"albumId"` + HasSyncedLyrics bool `json:"hasSyncedLyrics"` + HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"` + AvailableLanguages []string `json:"availableLanguages"` + OriginalLanguage string `json:"originalLanguage"` + SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"` + UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"` +} + +type musixmatchLyricsResponse struct { + ID int64 `json:"id"` + Duration int `json:"duration"` + Language string `json:"language"` + UpdatedTime string `json:"updatedTime"` + Lyrics string `json:"lyrics"` +} + +func NewMusixmatchClient() *MusixmatchClient { + return &MusixmatchClient{ + httpClient: NewMetadataHTTPClient(15 * time.Second), + baseURL: "http://158.180.60.95", + } +} + +// searchAndGetLyrics searches for a song and retrieves its lyrics in one call. +// The Musixmatch proxy returns both search result and lyrics in a single response. +func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) { + if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { + return nil, fmt.Errorf("empty track or artist name") + } + + encodedArtist := url.QueryEscape(artistName) + encodedTrack := url.QueryEscape(trackName) + + fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("musixmatch search failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) + } + + var result musixmatchSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode musixmatch response: %w", err) + } + + return &result, nil +} + +// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code. +func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) { + lang := strings.ToLower(strings.TrimSpace(language)) + if songID <= 0 || lang == "" { + return nil, fmt.Errorf("invalid song id or language") + } + + fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang)) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("musixmatch language fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode) + } + + var result musixmatchSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) + } + + // Prefer synced lyrics for selected language + if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { + lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil + } + } + + // Fall back to unsynced lyrics for selected language + if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { + var lines []LyricsLine + for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + } + + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "UNSYNCED", + PlainLyrics: result.UnsyncedLyrics.Lyrics, + Provider: "Musixmatch", + Source: fmt.Sprintf("Musixmatch (%s)", lang), + }, nil + } + } + + return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) +} + +// FetchLyrics searches Musixmatch and returns parsed LyricsResponse. +func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { + result, err := c.searchAndGetLyrics(trackName, artistName) + if err != nil { + return nil, err + } + + if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 { + localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred) + if localizedErr == nil { + return localized, nil + } + GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) + } + + // Prefer synced lyrics + if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { + lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil + } + } + + // Fall back to unsynced lyrics + if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { + var lines []LyricsLine + for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + } + + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "UNSYNCED", + PlainLyrics: result.UnsyncedLyrics.Lyrics, + Provider: "Musixmatch", + Source: "Musixmatch", + }, nil + } + } + + return nil, fmt.Errorf("no lyrics found on musixmatch") +} diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go new file mode 100644 index 00000000..e9fbf1e6 --- /dev/null +++ b/go_backend/lyrics_netease.go @@ -0,0 +1,209 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com). +// This is a direct public API — no proxy dependency. +type NeteaseClient struct { + httpClient *http.Client +} + +// Netease API response models +type neteaseSearchResponse struct { + Result struct { + Songs []struct { + Name string `json:"name"` + ID int64 `json:"id"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + } `json:"songs"` + SongCount int `json:"songCount"` + } `json:"result"` + Code int `json:"code"` +} + +type neteaseLyricsResponse struct { + LRC *neteaseLyricField `json:"lrc"` + TLyric *neteaseLyricField `json:"tlyric"` + RomaLRC *neteaseLyricField `json:"romalrc"` + Code int `json:"code"` +} + +type neteaseLyricField struct { + Lyric string `json:"lyric"` +} + +var neteaseHeaders = map[string]string{ + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "max-age=0", +} + +func NewNeteaseClient() *NeteaseClient { + return &NeteaseClient{ + httpClient: NewMetadataHTTPClient(15 * time.Second), + } +} + +// SearchSong searches for a song on Netease and returns the song ID. +func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) { + query := trackName + " " + artistName + if strings.TrimSpace(query) == "" { + return 0, fmt.Errorf("empty search query") + } + + searchURL := "http://music.163.com/api/search/pc" + params := url.Values{} + params.Set("s", query) + params.Set("type", "1") + params.Set("limit", "1") + params.Set("offset", "0") + + fullURL := searchURL + "?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range neteaseHeaders { + req.Header.Set(k, v) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("netease search failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode) + } + + var searchResp neteaseSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return 0, fmt.Errorf("failed to decode netease search: %w", err) + } + + if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 { + return 0, fmt.Errorf("no songs found on netease") + } + + return searchResp.Result.Songs[0].ID, nil +} + +// FetchLyricsByID fetches synced lyrics for a given Netease song ID. +func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) { + lyricsURL := "http://music.163.com/api/song/lyric" + params := url.Values{} + params.Set("id", fmt.Sprintf("%d", songID)) + params.Set("lv", "1") + params.Set("tv", "1") + params.Set("rv", "1") + + fullURL := lyricsURL + "?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + for k, v := range neteaseHeaders { + req.Header.Set(k, v) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("netease lyrics fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode) + } + + var lyricsResp neteaseLyricsResponse + if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil { + return "", fmt.Errorf("failed to decode netease lyrics: %w", err) + } + + if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" { + return "", fmt.Errorf("no lyrics available on netease") + } + + lyric := lyricsResp.LRC.Lyric + + if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" { + lyric += "\n\n" + lyricsResp.TLyric.Lyric + } + + if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" { + lyric += "\n\n" + lyricsResp.RomaLRC.Lyric + } + + return lyric, nil +} + +// FetchLyrics searches for a track and returns parsed LyricsResponse. +func (c *NeteaseClient) FetchLyrics( + trackName, + artistName string, + durationSec float64, + includeTranslation, + includeRomanization bool, +) (*LyricsResponse, error) { + songID, err := c.SearchSong(trackName, artistName) + if err != nil { + return nil, err + } + + lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization) + if err != nil { + return nil, err + } + + // Parse the LRC text into LyricsResponse + lines := parseSyncedLyrics(lrcText) + if len(lines) == 0 { + // May be plain text lyrics without timestamps + plainLines := strings.Split(lrcText, "\n") + for _, line := range plainLines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + lines = append(lines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + } + + if len(lines) == 0 { + return nil, fmt.Errorf("netease returned empty lyrics") + } + + return &LyricsResponse{ + Lines: lines, + SyncType: "UNSYNCED", + Provider: "Netease", + Source: "Netease", + }, nil + } + + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + Provider: "Netease", + Source: "Netease", + }, nil +} diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go new file mode 100644 index 00000000..654a3543 --- /dev/null +++ b/go_backend/lyrics_qqmusic.go @@ -0,0 +1,208 @@ +package gobackend + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// QQMusicClient fetches lyrics from QQ Music. +// Search uses public QQ Music API, lyrics use the paxsenix proxy. +type QQMusicClient struct { + httpClient *http.Client +} + +// QQ Music search response models +type qqMusicSearchResponse struct { + Data struct { + Song struct { + List []struct { + Title string `json:"title"` + Singer []struct { + Name string `json:"name"` + } `json:"singer"` + Album struct { + Name string `json:"name"` + } `json:"album"` + ID int64 `json:"id"` + } `json:"list"` + } `json:"song"` + } `json:"data"` +} + +// QQ Music lyrics request payload for paxsenix proxy +type qqLyricsPayload struct { + Artist []string `json:"artist"` + Album string `json:"album"` + ID int64 `json:"id"` + Title string `json:"title"` +} + +func NewQQMusicClient() *QQMusicClient { + return &QQMusicClient{ + httpClient: NewMetadataHTTPClient(15 * time.Second), + } +} + +// searchSong searches QQ Music and returns the song info needed for lyrics fetch. +func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) { + query := trackName + " " + artistName + if strings.TrimSpace(query) == "" { + return nil, fmt.Errorf("empty search query") + } + + searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" + params := url.Values{} + params.Set("format", "json") + params.Set("inCharset", "utf8") + params.Set("outCharset", "utf8") + params.Set("platform", "yqq.json") + params.Set("new_json", "1") + params.Set("w", query) + + fullURL := searchURL + "?" + params.Encode() + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("qqmusic search failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode) + } + + var searchResp qqMusicSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode qqmusic response: %w", err) + } + + if len(searchResp.Data.Song.List) == 0 { + return nil, fmt.Errorf("no songs found on qqmusic") + } + + song := searchResp.Data.Song.List[0] + + var artists []string + for _, singer := range song.Singer { + artists = append(artists, singer.Name) + } + + return &qqLyricsPayload{ + Artist: artists, + Album: song.Album.Name, + ID: song.ID, + Title: song.Title, + }, nil +} + +// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info. +func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) { + lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php" + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + 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 "", fmt.Errorf("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 +} + +// FetchLyrics searches QQ Music and returns parsed LyricsResponse. +func (c *QQMusicClient) FetchLyrics( + trackName, + artistName string, + durationSec float64, + multiPersonWordByWord bool, +) (*LyricsResponse, error) { + payload, err := c.searchSong(trackName, artistName) + if err != nil { + return nil, err + } + + rawLyrics, err := c.fetchLyricsByPayload(payload) + if err != nil { + return nil, err + } + + // Try to parse as pax format (word-by-word or line) + lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord) + if err != nil { + // If pax parsing fails, try to use as direct LRC text + lrcText = rawLyrics + } + + lines := parseSyncedLyrics(lrcText) + if len(lines) > 0 { + return &LyricsResponse{ + Lines: lines, + SyncType: "LINE_SYNCED", + Provider: "QQ Music", + Source: "QQ Music", + }, nil + } + + // Fall back to plain text + plainLines := strings.Split(lrcText, "\n") + var resultLines []LyricsLine + for _, line := range plainLines { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + resultLines = append(resultLines, LyricsLine{ + StartTimeMs: 0, + Words: trimmed, + EndTimeMs: 0, + }) + } + } + + if len(resultLines) > 0 { + return &LyricsResponse{ + Lines: resultLines, + SyncType: "UNSYNCED", + Provider: "QQ Music", + Source: "QQ Music", + }, nil + } + + return nil, fmt.Errorf("no lyrics found on qqmusic") +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b994eee9..c5968491 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -783,6 +783,36 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // Lyrics Provider Settings + case "setLyricsProviders": + let args = call.arguments as! [String: Any] + let providersJson = args["providers_json"] as? String ?? "[]" + GobackendSetLyricsProvidersJSON(providersJson, &error) + if let error = error { throw error } + return "{\"success\":true}" + + case "getLyricsProviders": + let response = GobackendGetLyricsProvidersJSON(&error) + if let error = error { throw error } + return response + + case "getAvailableLyricsProviders": + let response = GobackendGetAvailableLyricsProvidersJSON(&error) + if let error = error { throw error } + return response + + case "setLyricsFetchOptions": + let args = call.arguments as! [String: Any] + let optionsJson = args["options_json"] as? String ?? "{}" + GobackendSetLyricsFetchOptionsJSON(optionsJson, &error) + if let error = error { throw error } + return "{\"success\":true}" + + case "getLyricsFetchOptions": + let response = GobackendGetLyricsFetchOptionsJSON(&error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 415e7a22..ec889913 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -56,6 +56,18 @@ class AppSettings { final bool hasCompletedTutorial; // Track if user has completed the app tutorial + // Lyrics Provider Settings + final List + lyricsProviders; // Ordered list of enabled lyrics provider IDs + final bool + lyricsIncludeTranslationNetease; // Append translated lyrics (Netease) + final bool + lyricsIncludeRomanizationNetease; // Append romanized lyrics (Netease) + final bool + lyricsMultiPersonWordByWord; // Enable v1/v2 + [bg:] tags for Apple/QQ syllable lyrics + final String + musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics + const AppSettings({ this.defaultService = 'tidal', this.audioQuality = 'LOSSLESS', @@ -100,6 +112,12 @@ class AppSettings { this.localLibraryShowDuplicates = true, // Tutorial default this.hasCompletedTutorial = false, + // Lyrics providers default order + this.lyricsProviders = const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + this.lyricsIncludeTranslationNetease = false, + this.lyricsIncludeRomanizationNetease = false, + this.lyricsMultiPersonWordByWord = true, + this.musixmatchLanguage = '', }); AppSettings copyWith({ @@ -147,6 +165,12 @@ class AppSettings { bool? localLibraryShowDuplicates, // Tutorial bool? hasCompletedTutorial, + // Lyrics providers + List? lyricsProviders, + bool? lyricsIncludeTranslationNetease, + bool? lyricsIncludeRomanizationNetease, + bool? lyricsMultiPersonWordByWord, + String? musixmatchLanguage, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -202,6 +226,15 @@ class AppSettings { localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, // Tutorial hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, + // Lyrics providers + lyricsProviders: lyricsProviders ?? this.lyricsProviders, + lyricsIncludeTranslationNetease: + lyricsIncludeTranslationNetease ?? this.lyricsIncludeTranslationNetease, + lyricsIncludeRomanizationNetease: + lyricsIncludeRomanizationNetease ?? this.lyricsIncludeRomanizationNetease, + lyricsMultiPersonWordByWord: + lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, + musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 3775bc89..2002ffd1 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -53,50 +53,68 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( localLibraryShowDuplicates: json['localLibraryShowDuplicates'] as bool? ?? true, hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false, + lyricsProviders: + (json['lyricsProviders'] as List?) + ?.map((e) => e as String) + .toList() ?? + const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], + lyricsIncludeTranslationNetease: + json['lyricsIncludeTranslationNetease'] as bool? ?? false, + lyricsIncludeRomanizationNetease: + json['lyricsIncludeRomanizationNetease'] as bool? ?? false, + lyricsMultiPersonWordByWord: + json['lyricsMultiPersonWordByWord'] as bool? ?? true, + musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', ); -Map _$AppSettingsToJson(AppSettings instance) => - { - 'defaultService': instance.defaultService, - 'audioQuality': instance.audioQuality, - 'filenameFormat': instance.filenameFormat, - 'downloadDirectory': instance.downloadDirectory, - 'storageMode': instance.storageMode, - 'downloadTreeUri': instance.downloadTreeUri, - 'autoFallback': instance.autoFallback, - 'embedLyrics': instance.embedLyrics, - 'maxQualityCover': instance.maxQualityCover, - 'isFirstLaunch': instance.isFirstLaunch, - 'concurrentDownloads': instance.concurrentDownloads, - 'checkForUpdates': instance.checkForUpdates, - 'updateChannel': instance.updateChannel, - 'hasSearchedBefore': instance.hasSearchedBefore, - 'folderOrganization': instance.folderOrganization, - 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, - 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, - 'filterContributingArtistsInAlbumArtist': - instance.filterContributingArtistsInAlbumArtist, - 'historyViewMode': instance.historyViewMode, - 'historyFilterMode': instance.historyFilterMode, - 'askQualityBeforeDownload': instance.askQualityBeforeDownload, - 'spotifyClientId': instance.spotifyClientId, - 'spotifyClientSecret': instance.spotifyClientSecret, - 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, - 'metadataSource': instance.metadataSource, - 'enableLogging': instance.enableLogging, - 'useExtensionProviders': instance.useExtensionProviders, - 'searchProvider': instance.searchProvider, - 'separateSingles': instance.separateSingles, - 'albumFolderStructure': instance.albumFolderStructure, - 'showExtensionStore': instance.showExtensionStore, - 'locale': instance.locale, - 'lyricsMode': instance.lyricsMode, - 'tidalHighFormat': instance.tidalHighFormat, - 'useAllFilesAccess': instance.useAllFilesAccess, - 'autoExportFailedDownloads': instance.autoExportFailedDownloads, - 'downloadNetworkMode': instance.downloadNetworkMode, - 'localLibraryEnabled': instance.localLibraryEnabled, - 'localLibraryPath': instance.localLibraryPath, - 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, - 'hasCompletedTutorial': instance.hasCompletedTutorial, - }; +Map _$AppSettingsToJson( + AppSettings instance, +) => { + 'defaultService': instance.defaultService, + 'audioQuality': instance.audioQuality, + 'filenameFormat': instance.filenameFormat, + 'downloadDirectory': instance.downloadDirectory, + 'storageMode': instance.storageMode, + 'downloadTreeUri': instance.downloadTreeUri, + 'autoFallback': instance.autoFallback, + 'embedLyrics': instance.embedLyrics, + 'maxQualityCover': instance.maxQualityCover, + 'isFirstLaunch': instance.isFirstLaunch, + 'concurrentDownloads': instance.concurrentDownloads, + 'checkForUpdates': instance.checkForUpdates, + 'updateChannel': instance.updateChannel, + 'hasSearchedBefore': instance.hasSearchedBefore, + 'folderOrganization': instance.folderOrganization, + 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, + 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, + 'filterContributingArtistsInAlbumArtist': + instance.filterContributingArtistsInAlbumArtist, + 'historyViewMode': instance.historyViewMode, + 'historyFilterMode': instance.historyFilterMode, + 'askQualityBeforeDownload': instance.askQualityBeforeDownload, + 'spotifyClientId': instance.spotifyClientId, + 'spotifyClientSecret': instance.spotifyClientSecret, + 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, + 'metadataSource': instance.metadataSource, + 'enableLogging': instance.enableLogging, + 'useExtensionProviders': instance.useExtensionProviders, + 'searchProvider': instance.searchProvider, + 'separateSingles': instance.separateSingles, + 'albumFolderStructure': instance.albumFolderStructure, + 'showExtensionStore': instance.showExtensionStore, + 'locale': instance.locale, + 'lyricsMode': instance.lyricsMode, + 'tidalHighFormat': instance.tidalHighFormat, + 'useAllFilesAccess': instance.useAllFilesAccess, + 'autoExportFailedDownloads': instance.autoExportFailedDownloads, + 'downloadNetworkMode': instance.downloadNetworkMode, + 'localLibraryEnabled': instance.localLibraryEnabled, + 'localLibraryPath': instance.localLibraryPath, + 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, + 'hasCompletedTutorial': instance.hasCompletedTutorial, + 'lyricsProviders': instance.lyricsProviders, + 'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease, + 'lyricsIncludeRomanizationNetease': instance.lyricsIncludeRomanizationNetease, + 'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord, + 'musixmatchLanguage': instance.musixmatchLanguage, +}; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 03a8e13b..31722a7b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -26,6 +26,7 @@ class Extension { final List qualityOptions; final bool hasMetadataProvider; final bool hasDownloadProvider; + final bool hasLyricsProvider; final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final SearchBehavior? searchBehavior; final URLHandler? urlHandler; @@ -49,6 +50,7 @@ class Extension { this.qualityOptions = const [], this.hasMetadataProvider = false, this.hasDownloadProvider = false, + this.hasLyricsProvider = false, this.skipMetadataEnrichment = false, this.searchBehavior, this.urlHandler, @@ -78,6 +80,7 @@ class Extension { .toList() ?? [], hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, hasDownloadProvider: json['has_download_provider'] as bool? ?? false, + hasLyricsProvider: json['has_lyrics_provider'] as bool? ?? false, skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, searchBehavior: json['search_behavior'] != null ? SearchBehavior.fromJson(json['search_behavior'] as Map) @@ -111,6 +114,7 @@ class Extension { List? qualityOptions, bool? hasMetadataProvider, bool? hasDownloadProvider, + bool? hasLyricsProvider, bool? skipMetadataEnrichment, SearchBehavior? searchBehavior, URLHandler? urlHandler, @@ -134,6 +138,7 @@ class Extension { qualityOptions: qualityOptions ?? this.qualityOptions, hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, + hasLyricsProvider: hasLyricsProvider ?? this.hasLyricsProvider, skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, searchBehavior: searchBehavior ?? this.searchBehavior, urlHandler: urlHandler ?? this.urlHandler, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7bc96508..9e469cd2 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -39,6 +39,23 @@ class SettingsNotifier extends Notifier { _applySpotifyCredentials(); LogBuffer.loggingEnabled = state.enableLogging; + + _syncLyricsSettingsToBackend(); + } + + void _syncLyricsSettingsToBackend() { + PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { + _log.w('Failed to sync lyrics providers to backend: $e'); + }); + + PlatformBridge.setLyricsFetchOptions({ + 'include_translation_netease': state.lyricsIncludeTranslationNetease, + 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, + 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, + 'musixmatch_language': state.musixmatchLanguage, + }).catchError((e) { + _log.w('Failed to sync lyrics fetch options to backend: $e'); + }); } Future _runMigrations(SharedPreferences prefs) async { @@ -188,6 +205,36 @@ class SettingsNotifier extends Notifier { } } + void setLyricsProviders(List providers) { + state = state.copyWith(lyricsProviders: providers); + _saveSettings(); + _syncLyricsSettingsToBackend(); + } + + void setLyricsIncludeTranslationNetease(bool enabled) { + state = state.copyWith(lyricsIncludeTranslationNetease: enabled); + _saveSettings(); + _syncLyricsSettingsToBackend(); + } + + void setLyricsIncludeRomanizationNetease(bool enabled) { + state = state.copyWith(lyricsIncludeRomanizationNetease: enabled); + _saveSettings(); + _syncLyricsSettingsToBackend(); + } + + void setLyricsMultiPersonWordByWord(bool enabled) { + state = state.copyWith(lyricsMultiPersonWordByWord: enabled); + _saveSettings(); + _syncLyricsSettingsToBackend(); + } + + void setMusixmatchLanguage(String languageCode) { + state = state.copyWith(musixmatchLanguage: languageCode.trim().toLowerCase()); + _saveSettings(); + _syncLyricsSettingsToBackend(); + } + void setMaxQualityCover(bool enabled) { state = state.copyWith(maxQualityCover: enabled); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 51b9be18..456246a0 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -279,6 +279,61 @@ class _DownloadSettingsPageState extends ConsumerState { ref, settings.lyricsMode, ), + ), + SettingsItem( + icon: Icons.source_outlined, + title: 'Lyrics Providers', + subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders), + onTap: () => _showLyricsProvidersPicker( + context, + ref, + settings.lyricsProviders, + ), + ), + SettingsSwitchItem( + icon: Icons.translate_outlined, + title: 'Netease: Include Translation', + subtitle: settings.lyricsIncludeTranslationNetease + ? 'Append translated lyrics when available' + : 'Use original lyrics only', + value: settings.lyricsIncludeTranslationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeTranslationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.text_fields_outlined, + title: 'Netease: Include Romanization', + subtitle: settings.lyricsIncludeRomanizationNetease + ? 'Append romanized lyrics when available' + : 'Disabled', + value: settings.lyricsIncludeRomanizationNetease, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsIncludeRomanizationNetease(value), + ), + SettingsSwitchItem( + icon: Icons.record_voice_over_outlined, + title: 'Apple/QQ Multi-Person Word-by-Word', + subtitle: settings.lyricsMultiPersonWordByWord + ? 'Enable v1/v2 speaker and [bg:] tags' + : 'Simplified word-by-word formatting', + value: settings.lyricsMultiPersonWordByWord, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setLyricsMultiPersonWordByWord(value), + ), + SettingsItem( + icon: Icons.language_outlined, + title: 'Musixmatch Language', + subtitle: settings.musixmatchLanguage.isEmpty + ? 'Auto (original)' + : settings.musixmatchLanguage.toUpperCase(), + onTap: () => _showMusixmatchLanguagePicker( + context, + ref, + settings.musixmatchLanguage, + ), showDivider: false, ), ], @@ -1183,6 +1238,278 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + static const _providerDisplayNames = { + 'lrclib': 'LRCLIB', + 'netease': 'Netease', + 'musixmatch': 'Musixmatch', + 'apple_music': 'Apple Music', + 'qqmusic': 'QQ Music', + }; + + static const _providerDescriptions = { + 'lrclib': 'Open-source synced lyrics database', + 'netease': 'NetEase Cloud Music (good for Asian songs)', + 'musixmatch': 'Largest lyrics database (multi-language)', + 'apple_music': 'Word-by-word synced lyrics (via proxy)', + 'qqmusic': 'QQ Music (good for Chinese songs, via proxy)', + }; + + String _getLyricsProvidersSubtitle(List providers) { + if (providers.isEmpty) return 'None enabled'; + return providers + .map((p) => _providerDisplayNames[p] ?? p) + .join(' > '); + } + + void _showLyricsProvidersPicker( + BuildContext context, + WidgetRef ref, + List currentProviders, + ) { + final colorScheme = Theme.of(context).colorScheme; + final allProviders = ['lrclib', 'netease', 'musixmatch', 'apple_music', 'qqmusic']; + + // Work with a mutable copy + final selectedProviders = List.from(currentProviders); + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, setLocalState) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Lyrics Providers', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text( + 'Enable/disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + // Reorderable list of providers + ...allProviders.map((providerId) { + final isEnabled = selectedProviders.contains(providerId); + final displayName = _providerDisplayNames[providerId] ?? providerId; + final description = _providerDescriptions[providerId] ?? ''; + final orderIndex = selectedProviders.indexOf(providerId); + + return CheckboxListTile( + title: Row( + children: [ + if (isEnabled) + Padding( + padding: const EdgeInsets.only(right: 8), + child: CircleAvatar( + radius: 12, + backgroundColor: colorScheme.primaryContainer, + child: Text( + '${orderIndex + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ), + Text(displayName), + ], + ), + subtitle: Text(description), + value: isEnabled, + onChanged: (bool? value) { + setLocalState(() { + if (value == true) { + selectedProviders.add(providerId); + } else { + selectedProviders.remove(providerId); + } + }); + ref.read(settingsProvider.notifier).setLyricsProviders( + List.from(selectedProviders), + ); + }, + ); + }), + // Move up/down hint + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 4), + child: Text( + 'Priority order (tap to move):', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + // Show enabled providers with move controls + ...selectedProviders.asMap().entries.map((entry) { + final index = entry.key; + final providerId = entry.value; + final displayName = _providerDisplayNames[providerId] ?? providerId; + + return ListTile( + dense: true, + leading: CircleAvatar( + radius: 14, + backgroundColor: colorScheme.primary, + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: colorScheme.onPrimary, + ), + ), + ), + title: Text(displayName), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (index > 0) + IconButton( + icon: const Icon(Icons.arrow_upward, size: 20), + onPressed: () { + setLocalState(() { + selectedProviders.removeAt(index); + selectedProviders.insert(index - 1, providerId); + }); + ref.read(settingsProvider.notifier).setLyricsProviders( + List.from(selectedProviders), + ); + }, + ), + if (index < selectedProviders.length - 1) + IconButton( + icon: const Icon(Icons.arrow_downward, size: 20), + onPressed: () { + setLocalState(() { + selectedProviders.removeAt(index); + selectedProviders.insert(index + 1, providerId); + }); + ref.read(settingsProvider.notifier).setLyricsProviders( + List.from(selectedProviders), + ); + }, + ), + ], + ), + ); + }), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } + + String _normalizeMusixmatchLanguage(String value) { + final normalized = value.trim().toLowerCase(); + return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); + } + + void _showMusixmatchLanguagePicker( + BuildContext context, + WidgetRef ref, + String currentLanguage, + ) { + final colorScheme = Theme.of(context).colorScheme; + final controller = TextEditingController(text: currentLanguage); + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 24, + bottom: 24 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Musixmatch Language', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Set preferred language code (example: en, es, ja). Leave empty for auto.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: controller, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Language code', + hintText: 'auto / en / es / ja', + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.dialogCancel), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () { + ref.read(settingsProvider.notifier).setMusixmatchLanguage(''); + Navigator.pop(context); + }, + child: const Text('Auto'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final normalized = _normalizeMusixmatchLanguage( + controller.text, + ); + ref + .read(settingsProvider.notifier) + .setMusixmatchLanguage(normalized); + Navigator.pop(context); + }, + child: Text(context.l10n.dialogSave), + ), + ], + ), + ], + ), + ), + ); + } + String _getTidalHighFormatLabel(String format) { switch (format) { case 'mp3_320': diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 090978e0..2268a793 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -218,6 +218,11 @@ class _ExtensionDetailPageState extends ConsumerState { title: context.l10n.extensionDownloadProvider, enabled: extension.hasDownloadProvider, ), + _CapabilityItem( + icon: Icons.lyrics, + title: context.l10n.extensionLyricsProvider, + enabled: extension.hasLyricsProvider, + ), _CapabilityItem( icon: Icons.manage_search, title: context.l10n.extensionsSearchProvider, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b879bf5d..8284c4f1 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -332,6 +332,44 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + // ==================== LYRICS PROVIDER SETTINGS ==================== + + /// Sets the lyrics provider order. Providers not in the list are disabled. + static Future setLyricsProviders(List providers) async { + final providersJSON = jsonEncode(providers); + await _channel.invokeMethod('setLyricsProviders', { + 'providers_json': providersJSON, + }); + } + + /// Returns the current lyrics provider order. + static Future> getLyricsProviders() async { + final result = await _channel.invokeMethod('getLyricsProviders'); + final List decoded = jsonDecode(result as String) as List; + return decoded.cast(); + } + + /// Returns metadata about all available lyrics providers. + static Future>> getAvailableLyricsProviders() async { + final result = await _channel.invokeMethod('getAvailableLyricsProviders'); + final List decoded = jsonDecode(result as String) as List; + return decoded.cast>(); + } + + /// Sets advanced lyrics fetch options used by provider-specific integrations. + static Future setLyricsFetchOptions(Map options) async { + final optionsJSON = jsonEncode(options); + await _channel.invokeMethod('setLyricsFetchOptions', { + 'options_json': optionsJSON, + }); + } + + /// Returns current advanced lyrics fetch options. + static Future> getLyricsFetchOptions() async { + final result = await _channel.invokeMethod('getLyricsFetchOptions'); + return jsonDecode(result as String) as Map; + } + static Future> reEnrichFile( Map request, ) async { diff --git a/site/docs.html b/site/docs.html index 77edff50..2ad5c7f6 100644 --- a/site/docs.html +++ b/site/docs.html @@ -638,6 +638,7 @@
  • Track Enrichment
  • Custom Track Matching
  • Post-Processing Hooks
  • +
  • Lyrics Provider
  • Main Script @@ -667,6 +668,7 @@
  • Packaging & Distribution @@ -723,6 +725,7 @@
  • Album Object
  • Artist Object
  • Download Result Object
  • +
  • Lyrics Result Object
  • Skip Metadata Enrichment
  • @@ -754,6 +757,7 @@
  • Track Enrichment
  • Custom Track Matching
  • Post-Processing Hooks
  • +
  • Lyrics Provider
  • Main Script
  • @@ -791,6 +795,7 @@
    • Metadata Provider: New track/album/artist search sources
    • Download Provider: New audio download sources
    • +
    • Lyrics Provider: Custom lyrics sources (synced or plain text)

    Extensions are written in JavaScript and run in a secure sandbox.

    Requirements

    @@ -936,7 +941,7 @@ type array Yes -Extension type (metadata_provider, download_provider) +Extension type (metadata_provider, download_provider, lyrics_provider) settings @@ -1224,7 +1229,8 @@

    Specify the features provided by the extension through the type field:

    "type": [
       "metadata_provider",   // Provides search/metadata
    -  "download_provider"    // Provides downloads
    +  "download_provider",   // Provides downloads
    +  "lyrics_provider"      // Provides lyrics (synced or plain)
     ]
     

    Settings

    @@ -2832,6 +2838,171 @@ If present, it will be called before postProcess.

  • postProcessV2 is designed for SAF (Android 10+) and content URIs.
  • postProcess (v1) remains supported but will be deprecated in a future release.
  • +

    Lyrics Provider

    +

    Extensions can provide lyrics for tracks by declaring "lyrics_provider" in the type array. Lyrics provider extensions are called before built-in providers (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music), giving extensions the highest priority in the lyrics cascade.

    +

    Manifest configuration:

    +
    {
    +  "name": "my-lyrics-source",
    +  "displayName": "My Lyrics Source",
    +  "version": "1.0.0",
    +  "description": "Fetch lyrics from My Lyrics API",
    +  "author": "Developer",
    +  "type": ["lyrics_provider"],
    +  "permissions": {
    +    "network": ["api.my-lyrics.com"],
    +    "storage": true
    +  }
    +}
    +
    +

    Implement the fetchLyrics function:

    +
    /**
    + * Fetch lyrics for a track
    + * @param {string} trackName - Track title
    + * @param {string} artistName - Artist name
    + * @param {string} albumName - Album name (may be empty)
    + * @param {number} durationSec - Track duration in seconds
    + * @returns {Object|null} Lyrics result object, or null if not found
    + */
    +function fetchLyrics(trackName, artistName, albumName, durationSec) {
    +  var query = encodeURIComponent(trackName + " " + artistName);
    +  var resp = http.get("https://api.my-lyrics.com/search?q=" + query, {});
    +
    +  if (!resp.ok) {
    +    return null;  // Return null to let the next provider try
    +  }
    +
    +  var data = JSON.parse(resp.body);
    +  if (!data || !data.lyrics) {
    +    return null;
    +  }
    +
    +  // For synced lyrics (LRC / timed lines)
    +  if (data.syncedLines) {
    +    var lines = [];
    +    for (var i = 0; i < data.syncedLines.length; i++) {
    +      var line = data.syncedLines[i];
    +      lines.push({
    +        startTimeMs: line.startMs,
    +        words: line.text,
    +        endTimeMs: line.endMs || 0
    +      });
    +    }
    +    return {
    +      lines: lines,
    +      syncType: "LINE_SYNCED",
    +      instrumental: false,
    +      plainLyrics: "",
    +      provider: "My Lyrics Source"
    +    };
    +  }
    +
    +  // For plain/unsynced lyrics
    +  return {
    +    lines: [],
    +    syncType: "UNSYNCED",
    +    instrumental: false,
    +    plainLyrics: data.lyrics,
    +    provider: "My Lyrics Source"
    +  };
    +}
    +
    +// Register extension
    +registerExtension({
    +  initialize: function(config) { return true; },
    +  cleanup: function() {},
    +  fetchLyrics: fetchLyrics
    +});
    +
    +

    Return schema for fetchLyrics:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeRequiredDescription
    linesarrayNoArray of synced lyric line objects (see below)
    syncTypestringYes"LINE_SYNCED", "WORD_SYNCED", or "UNSYNCED"
    instrumentalbooleanNoIf true, track is instrumental (no lyrics)
    plainLyricsstringNoPlain text lyrics (fallback when no synced lines)
    providerstringNoProvider name for attribution (defaults to extension displayName)
    +

    Lyrics line object:

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    startTimeMsnumberStart time in milliseconds
    wordsstringThe lyric text for this line
    endTimeMsnumberEnd time in milliseconds (optional, 0 if unknown)
    +

    Sync types explained:

    +
      +
    • LINE_SYNCED — Each line has a start timestamp (standard LRC format). Most common for karaoke-style lyrics.
    • +
    • WORD_SYNCED — Each word has individual timestamps. Used by Apple Music and some premium services.
    • +
    • UNSYNCED — Plain text without timestamps. Used as a last resort fallback.
    • +
    +

    Returning null vs empty result:

    +
      +
    • Return null to signal “not found” — the next provider in the cascade will be tried.
    • +
    • Return an object with instrumental: true to indicate an instrumental track (stops the cascade).
    • +
    • Return an object with plainLyrics as a fallback when synced lines are unavailable — the runtime will auto-split plain text into unsynced lines.
    • +
    +

    Lyrics provider cascade order:

    +
      +
    1. Extension lyrics providers (highest priority, tried first)
    2. +
    3. Built-in providers in user-configured order (default: LRCLIB → Musixmatch → Netease → Apple Music → QQ Music)
    4. +
    +
    +Note: If multiple lyrics extensions are installed, they are sorted alphabetically by extension ID and tried in that order. +

    Main Script

    The main.js file (or index.js) contains the extension's JavaScript code.

    @@ -4303,6 +4474,113 @@ registerExtension({ console.log("Premium Music extension loaded!"); +

    Example 3: Lyrics Provider

    +

    Extension that fetches synced lyrics from a public lyrics API.

    +

    manifest.json:

    +
    {
    +  "name": "open-lyrics",
    +  "displayName": "Open Lyrics",
    +  "version": "1.0.0",
    +  "description": "Fetch synced and plain lyrics from Open Lyrics API",
    +  "author": "Developer",
    +  "permissions": {
    +    "network": ["api.open-lyrics.com"]
    +  },
    +  "type": ["lyrics_provider"]
    +}
    +
    +

    index.js:

    +
    function initialize(config) {
    +  log.info("Open Lyrics extension initialized");
    +  return true;
    +}
    +
    +function cleanup() {}
    +
    +/**
    + * Fetch lyrics for a track.
    + * Called by the runtime with track metadata.
    + * Return null if no lyrics are found (next provider will be tried).
    + */
    +function fetchLyrics(trackName, artistName, albumName, durationSec) {
    +  // Search for the track
    +  var query = encodeURIComponent(trackName + " " + artistName);
    +  var resp = http.get("https://api.open-lyrics.com/v1/search?q=" + query + "&duration=" + Math.round(durationSec), {});
    +
    +  if (!resp.ok) {
    +    log.warn("Search failed with status " + resp.statusCode);
    +    return null;
    +  }
    +
    +  var data = JSON.parse(resp.body);
    +  if (!data.results || data.results.length === 0) {
    +    return null;
    +  }
    +
    +  var trackId = data.results[0].id;
    +
    +  // Fetch lyrics by ID
    +  var lyricsResp = http.get("https://api.open-lyrics.com/v1/lyrics/" + trackId, {});
    +  if (!lyricsResp.ok) {
    +    return null;
    +  }
    +
    +  var lyricsData = JSON.parse(lyricsResp.body);
    +
    +  // Instrumental track
    +  if (lyricsData.instrumental) {
    +    return {
    +      lines: [],
    +      syncType: "LINE_SYNCED",
    +      instrumental: true,
    +      plainLyrics: "",
    +      provider: "Open Lyrics"
    +    };
    +  }
    +
    +  // Synced lyrics available
    +  if (lyricsData.syncedLines && lyricsData.syncedLines.length > 0) {
    +    var lines = [];
    +    for (var i = 0; i < lyricsData.syncedLines.length; i++) {
    +      var line = lyricsData.syncedLines[i];
    +      lines.push({
    +        startTimeMs: line.timeMs,
    +        words: line.text,
    +        endTimeMs: (i + 1 < lyricsData.syncedLines.length)
    +          ? lyricsData.syncedLines[i + 1].timeMs
    +          : line.timeMs + 5000
    +      });
    +    }
    +    return {
    +      lines: lines,
    +      syncType: "LINE_SYNCED",
    +      instrumental: false,
    +      plainLyrics: "",
    +      provider: "Open Lyrics"
    +    };
    +  }
    +
    +  // Fallback: plain text lyrics only
    +  if (lyricsData.plainText) {
    +    return {
    +      lines: [],
    +      syncType: "UNSYNCED",
    +      instrumental: false,
    +      plainLyrics: lyricsData.plainText,
    +      provider: "Open Lyrics"
    +    };
    +  }
    +
    +  return null;
    +}
    +
    +// REQUIRED: Register the extension
    +registerExtension({
    +  initialize: initialize,
    +  cleanup: cleanup,
    +  fetchLyrics: fetchLyrics
    +});
    +

    Packaging & Distribution

    Project Structure

    @@ -5124,6 +5402,65 @@ registerExtension({ error_type: "not_found" | "stream_error" | "download_error" | "auth_error" } +

    Lyrics Result Object

    +

    Returned by the fetchLyrics() function in lyrics provider extensions.

    +
    // Synced lyrics
    +{
    +  lines: [
    +    { startTimeMs: 1200, words: "First line of lyrics", endTimeMs: 4500 },
    +    { startTimeMs: 4500, words: "Second line of lyrics", endTimeMs: 8200 }
    +  ],
    +  syncType: "LINE_SYNCED",   // "LINE_SYNCED", "WORD_SYNCED", or "UNSYNCED"
    +  instrumental: false,
    +  plainLyrics: "",
    +  provider: "My Lyrics Source"
    +}
    +
    +// Plain text lyrics (no timestamps)
    +{
    +  lines: [],
    +  syncType: "UNSYNCED",
    +  instrumental: false,
    +  plainLyrics: "Line one\nLine two\nLine three",
    +  provider: "My Lyrics Source"
    +}
    +
    +// Instrumental track
    +{
    +  lines: [],
    +  syncType: "LINE_SYNCED",
    +  instrumental: true,
    +  plainLyrics: "",
    +  provider: "My Lyrics Source"
    +}
    +
    +

    Lyrics line fields:

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldTypeDescription
    startTimeMsnumberLine start time in milliseconds
    wordsstringThe lyric text for this line
    endTimeMsnumberLine end time in milliseconds (0 if unknown; auto-computed from next line)

    Skip Metadata Enrichment

    When skipMetadataEnrichment is set to true in the manifest, SpotiFLAC will use the metadata returned by the extension's download() function instead of enriching from Deezer/Spotify. This is useful for:

      @@ -5153,6 +5490,7 @@ registerExtension({

      Changelog

        +
      • v1.3 - Added lyrics_provider extension type, fetchLyrics API, lyrics result schema, and Lyrics Provider example
      • v1.2 - Added thumbnail ratio customization (thumbnailRatio, thumbnailWidth, thumbnailHeight)
      • v1.1 - Added extension upgrade support (no downgrade), improved documentation
      • v1.0 - Initial release
      • @@ -5213,7 +5551,7 @@ registerExtension({