diff --git a/CHANGELOG.md b/CHANGELOG.md index 8273df1d..78eccb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [3.6.8] - 2026-02-14 + +### Added + +- **Lyrics Source Tracking**: Track Metadata screen now displays the source of loaded lyrics (LRCLIB, Musixmatch, Netease, Apple Music, QQ Music, Embedded, or Extension) + - New `getLyricsLRCWithSource` API returns lyrics with source metadata + - Source badge appears below lyrics section in Track Metadata screen +- **Dedicated Lyrics Provider Priority Page**: Lyrics providers can now be configured from a dedicated settings page with full-screen reorderable list + - Replaced inline bottom sheet with `LyricsProviderPriorityPage` + - Cleaner UI with provider descriptions and priority ordering +- **Paxsenix Integration**: Added Paxsenix API as official lyrics proxy partner for Apple Music, QQ Music, Musixmatch, and Netease sources + - Listed in About page and Partners page on project site + - README updated with partner attribution + +### Fixed + +- **LRC Background Vocal Preservation**: Apple Music/QQ Music `[bg:...]` background vocal tags are now preserved during LRC parsing instead of being stripped + - Background vocals attach to the previous timed line in exported LRC files +- **LRC Display Improvements**: + - Inline word-by-word timestamps (``) are stripped from lyrics display + - Speaker prefixes (`v1:`, `v2:`) are removed for cleaner display + - Multi-line background vocals converted to readable secondary vocal lines +- **Apple Music Lyrics Case Sensitivity**: Fixed `lyricsType` comparison to use case-insensitive matching for "Syllable" type + +### Changed + +- Track Metadata lyrics fetching now uses `getLyricsLRCWithSource` for consistent source attribution across embedded and online lyrics + +--- + ## [3.6.7] - 2026-02-13 ### Added @@ -16,6 +46,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/README.md b/README.md index 22fc0c81..a44bbb96 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The software is provided "as is", without warranty of any kind. The author assum - **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net) - **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) - **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) -- **Lyrics**: [LRCLib](https://lrclib.net) +- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy) - **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com) - **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) 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..16e7ffbb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1582,6 +1582,32 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "getLyricsLRCWithSource" -> { + val spotifyId = call.argument("spotify_id") ?: "" + val trackName = call.argument("track_name") ?: "" + val artistName = call.argument("artist_name") ?: "" + val filePath = call.argument("file_path") ?: "" + val durationMs = call.argument("duration_ms")?.toLong() ?: 0L + val response = withContext(Dispatchers.IO) { + if (filePath.startsWith("content://")) { + val tempPath = copyUriToTemp(Uri.parse(filePath)) + if (tempPath == null) { + """{"lyrics":"","source":"","sync_type":"","instrumental":false}""" + } else { + try { + Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, tempPath, durationMs) + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + } else { + Gobackend.getLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs) + } + } + result.success(response) + } "embedLyricsToFile" -> { val filePath = call.argument("file_path") ?: "" val lyrics = call.argument("lyrics") ?: "" @@ -1756,6 +1782,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..3adfcb9f 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1008,6 +1008,64 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura return lrcContent, nil } +func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { + if filePath != "" { + lyrics, err := ExtractLyrics(filePath) + if err == nil && lyrics != "" { + result := map[string]interface{}{ + "lyrics": lyrics, + "source": "Embedded", + "sync_type": "EMBEDDED", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + result := map[string]interface{}{ + "lyrics": "", + "source": "", + "sync_type": "", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return "", err + } + + lrcContent := "" + if lyricsData.Instrumental { + lrcContent = "[instrumental:true]" + } else { + lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName) + } + + result := map[string]interface{}{ + "lyrics": lrcContent, + "source": lyricsData.Source, + "sync_type": lyricsData.SyncType, + "instrumental": lyricsData.Instrumental, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func EmbedLyricsToFile(filePath, lyrics string) (string, error) { err := EmbedLyrics(filePath, lyrics) if err != nil { @@ -1599,6 +1657,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..576772f7 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 @@ -90,6 +224,15 @@ func (c *lyricsCache) Size() int { return len(c.cache) } +func (c *lyricsCache) ClearAll() int { + c.mu.Lock() + defer c.mu.Unlock() + + cleared := len(c.cache) + c.cache = make(map[string]*lyricsCacheEntry) + return cleared +} + type LRCLibResponse struct { ID int `json:"id"` Name string `json:"name"` @@ -139,7 +282,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 +317,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 +383,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() + extManager := GetExtensionManager() + var extensionProviders []*ExtensionProviderWrapper + if extManager != nil { + extensionProviders = extManager.GetLyricsProviders() + } + + var cachedNonExtension *LyricsResponse 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)" + 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 lyricsHasUsableText(l) + } + + // 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 } - var lyrics *LyricsResponse - var err error - - 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 - } - } - + // 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, @@ -339,10 +617,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine { continue } + // Preserve Apple/QQ background vocal tags by attaching them to + // the previous timed line. This keeps [bg:...] in final exported LRC. + if strings.HasPrefix(line, "[bg:") && len(lines) > 0 { + lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line) + continue + } + matches := lrcPattern.FindStringSubmatch(line) if len(matches) == 5 { startMs := lrcTimestampToMs(matches[1], matches[2], matches[3]) words := strings.TrimSpace(matches[4]) + if words == "" { + continue + } lines = append(lines, LyricsLine{ StartTimeMs: startMs, @@ -363,6 +651,63 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine { return lines } +func lyricsHasUsableText(lyrics *LyricsResponse) bool { + if lyrics == nil { + return false + } + if lyrics.Instrumental { + return true + } + if strings.TrimSpace(lyrics.PlainLyrics) != "" { + return true + } + for _, line := range lyrics.Lines { + if strings.TrimSpace(line.Words) != "" { + return true + } + } + return false +} + +// detectLyricsErrorPayload extracts human-readable error messages from +// JSON payloads returned by lyrics proxies when no lyric is available. +func detectLyricsErrorPayload(raw string) (string, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return "", false + } + + var payload map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return "", false + } + + lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"} + hasLyricsKey := false + for _, key := range lyricsKeys { + if _, ok := payload[key]; ok { + hasLyricsKey = true + break + } + } + + errorKeys := []string{"message", "error", "detail", "reason"} + for _, key := range errorKeys { + if msg, ok := payload[key].(string); ok { + msg = strings.TrimSpace(msg) + if msg != "" && !hasLyricsKey { + return msg, true + } + } + } + + if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey { + return "request unsuccessful", true + } + + return "", false +} + func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 { min, _ := strconv.ParseInt(minutes, 10, 64) sec, _ := strconv.ParseInt(seconds, 10, 64) @@ -376,12 +721,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..41d650f8 --- /dev/null +++ b/go_backend/lyrics_apple.go @@ -0,0 +1,381 @@ +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 strings.EqualFold(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 + } + if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload { + return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg) + } + + // 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..6971ba24 --- /dev/null +++ b/go_backend/lyrics_qqmusic.go @@ -0,0 +1,211 @@ +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 + } + if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload { + return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg) + } + + // 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..6c6a9ab6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -191,6 +191,17 @@ import Gobackend // Import Go framework let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error) if let error = error { throw error } return response + + case "getLyricsLRCWithSource": + let args = call.arguments as! [String: Any] + let spotifyId = args["spotify_id"] as! String + let trackName = args["track_name"] as! String + let artistName = args["artist_name"] as! String + let filePath = args["file_path"] as? String ?? "" + let durationMs = args["duration_ms"] as? Int64 ?? 0 + let response = GobackendGetLyricsLRCWithSource(spotifyId, trackName, artistName, filePath, durationMs, &error) + if let error = error { throw error } + return response case "embedLyricsToFile": let args = call.arguments as! [String: Any] @@ -783,6 +794,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/constants/app_info.dart b/lib/constants/app_info.dart index 7a8d737e..e3d1f6fd 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.6.7'; - static const String buildNumber = '81'; + static const String version = '3.6.8'; + static const String buildNumber = '82'; static const String fullVersion = '$version+$buildNumber'; 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/about_page.dart b/lib/screens/settings/about_page.dart index 4131282c..ba8d7bc8 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -141,6 +141,14 @@ class AboutPage extends StatelessWidget { title: context.l10n.aboutSpotiSaver, subtitle: context.l10n.aboutSpotiSaverDesc, onTap: () => _launchUrl('https://spotisaver.net'), + showDivider: true, + ), + _AboutSettingsItem( + icon: Icons.lyrics_outlined, + title: 'Paxsenix', + subtitle: + 'Partner lyrics proxy for Apple Music and QQ Music sources', + onTap: () => _launchUrl('https://lyrics.paxsenix.org'), showDivider: false, ), ], diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 56a7aefe..c71cd4db 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -68,7 +68,9 @@ class DonatePage extends StatelessWidget { // Combined notice card Card( elevation: 0, - color: colorScheme.secondaryContainer.withValues(alpha: 0.3), + color: colorScheme.secondaryContainer.withValues( + alpha: 0.3, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), @@ -98,7 +100,8 @@ class DonatePage extends StatelessWidget { const SizedBox(height: 10), _NoticeLine( icon: Icons.block, - text: 'Not selling early access, premium features, or paywalls', + text: + 'Not selling early access, premium features, or paywalls', colorScheme: colorScheme, ), const SizedBox(height: 6), @@ -110,36 +113,40 @@ class DonatePage extends StatelessWidget { const SizedBox(height: 6), _NoticeLine( icon: Icons.favorite_border, - text: 'Your support is the only way to keep this project alive', + text: + 'Your support is the only way to keep this project alive', colorScheme: colorScheme, ), Divider( height: 24, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), + color: colorScheme.outlineVariant.withValues( + alpha: 0.3, + ), ), _NoticeLine( icon: Icons.history, - text: 'Your name stays permanently in every version it was included in', + text: + 'Your name stays permanently in every version it was included in', colorScheme: colorScheme, ), const SizedBox(height: 6), _NoticeLine( icon: Icons.update, - text: 'Supporter list is updated monthly and embedded in the app', + text: + 'Supporter list is updated monthly and embedded in the app', colorScheme: colorScheme, ), const SizedBox(height: 6), _NoticeLine( icon: Icons.cloud_off, - text: 'No remote server -- everything is stored locally', + text: + 'No remote server -- everything is stored locally', colorScheme: colorScheme, ), ], ), ), ), - - ], ), ), @@ -205,6 +212,7 @@ class _RecentDonorsCard extends StatelessWidget { _DonorTile(name: 'matt_3050', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile(name: '283Fabio', colorScheme: colorScheme), + _DonorTile(name: 'laflame', colorScheme: colorScheme), _DonorTile( name: 'Elias el Autentico', colorScheme: colorScheme, @@ -414,9 +422,9 @@ class _NoticeLine extends StatelessWidget { Expanded( child: Text( text, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface), ), ), ], diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index dd3c807e..125cc626 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { @@ -279,6 +280,62 @@ class _DownloadSettingsPageState extends ConsumerState { ref, settings.lyricsMode, ), + ), + SettingsItem( + icon: Icons.source_outlined, + title: 'Lyrics Providers', + subtitle: _getLyricsProvidersSubtitle(settings.lyricsProviders), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LyricsProviderPriorityPage(), + ), + ), + ), + 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, ), ], @@ -1195,6 +1252,111 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + static const _providerDisplayNames = { + 'lrclib': 'LRCLIB', + 'netease': 'Netease', + 'musixmatch': 'Musixmatch', + 'apple_music': 'Apple Music', + 'qqmusic': 'QQ Music', + }; + + String _getLyricsProvidersSubtitle(List providers) { + if (providers.isEmpty) return 'None enabled'; + return providers + .map((p) => _providerDisplayNames[p] ?? p) + .join(' > '); + } + + 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/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart new file mode 100644 index 00000000..58abaea3 --- /dev/null +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -0,0 +1,572 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class LyricsProviderPriorityPage extends ConsumerStatefulWidget { + const LyricsProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => + _LyricsProviderPriorityPageState(); +} + +class _LyricsProviderPriorityPageState + extends ConsumerState { + static const _allProviderIds = [ + 'lrclib', + 'netease', + 'musixmatch', + 'apple_music', + 'qqmusic', + ]; + + late List _enabledProviders; + late List _initialProviders; + bool _hasChanges = false; + + List get _disabledProviders => _allProviderIds + .where((id) => !_enabledProviders.contains(id)) + .toList(); + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider); + _enabledProviders = List.from(settings.lyricsProviders); + _initialProviders = List.from(settings.lyricsProviders); + } + + void _markChanged() { + final changed = _enabledProviders.length != _initialProviders.length || + !_enabledProviders + .asMap() + .entries + .every((e) => + e.key < _initialProviders.length && + _initialProviders[e.key] == e.value); + setState(() => _hasChanges = changed); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + final disabled = _disabledProviders; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // ── Collapsing App Bar ── + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: + EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Lyrics Providers', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // ── Description ── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 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, + ), + ), + ), + ), + + // ── Enabled section header ── + if (_enabledProviders.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Enabled (${_enabledProviders.length})', + ), + ), + + // ── Reorderable enabled list ── + if (_enabledProviders.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _enabledProviders.length, + itemBuilder: (context, index) { + final id = _enabledProviders[index]; + final info = _getLyricsProviderInfo(id); + return _EnabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + index: index, + isFirst: index == 0, + onToggle: () => _disableProvider(id), + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _enabledProviders.removeAt(oldIndex); + _enabledProviders.insert(newIndex, item); + }); + _markChanged(); + }, + ), + ), + + // ── Disabled section header ── + if (disabled.isNotEmpty) + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: 'Disabled (${disabled.length})', + ), + ), + + // ── Disabled list ── + if (disabled.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final id = disabled[index]; + final info = _getLyricsProviderInfo(id); + return _DisabledProviderItem( + key: ValueKey(id), + providerId: id, + info: info, + onToggle: () => _enableProvider(id), + ); + }, + childCount: disabled.length, + ), + ), + ), + + // ── Info banner ── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, + size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Extension lyrics providers always run before ' + 'built-in providers. At least one provider must ' + 'remain enabled.', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + // ── State mutations ── + + void _enableProvider(String id) { + setState(() => _enabledProviders.add(id)); + _markChanged(); + } + + void _disableProvider(String id) { + if (_enabledProviders.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('At least one provider must remain enabled'), + ), + ); + return; + } + setState(() => _enabledProviders.remove(id)); + _markChanged(); + } + + // ── Save / Discard ── + + Future _saveChanges() async { + ref + .read(settingsProvider.notifier) + .setLyricsProviders(List.from(_enabledProviders)); + setState(() { + _initialProviders = List.from(_enabledProviders); + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Lyrics provider priority saved')), + ); + } + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard changes?'), + content: + const Text('You have unsaved changes that will be lost.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + // ── Provider metadata ── + + static _LyricsProviderInfo _getLyricsProviderInfo(String id) { + switch (id) { + case 'lrclib': + return _LyricsProviderInfo( + name: 'LRCLIB', + description: 'Open-source synced lyrics database', + icon: Icons.subtitles_outlined, + ); + case 'netease': + return _LyricsProviderInfo( + name: 'Netease', + description: 'NetEase Cloud Music (good for Asian songs)', + icon: Icons.cloud_outlined, + ); + case 'musixmatch': + return _LyricsProviderInfo( + name: 'Musixmatch', + description: 'Largest lyrics database (multi-language)', + icon: Icons.translate, + ); + case 'apple_music': + return _LyricsProviderInfo( + name: 'Apple Music', + description: 'Word-by-word synced lyrics (via proxy)', + icon: Icons.music_note, + ); + case 'qqmusic': + return _LyricsProviderInfo( + name: 'QQ Music', + description: 'QQ Music (good for Chinese songs, via proxy)', + icon: Icons.queue_music, + ); + default: + return _LyricsProviderInfo( + name: id, + description: 'Extension provider', + icon: Icons.extension, + ); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Enabled provider card (reorderable) +// ═══════════════════════════════════════════════════════════════════════════ + +class _EnabledProviderItem extends StatelessWidget { + final String providerId; + final _LyricsProviderInfo info; + final int index; + final bool isFirst; + final VoidCallback onToggle; + + const _EnabledProviderItem({ + super.key, + required this.providerId, + required this.info, + required this.index, + required this.isFirst, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Numbered badge + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Icon + Icon(info.icon, color: colorScheme.primary), + const SizedBox(width: 12), + // Name + description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.description, + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Enable/disable switch + SizedBox( + height: 32, + child: FittedBox( + child: Switch( + value: true, + onChanged: (_) => onToggle(), + ), + ), + ), + const SizedBox(width: 4), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Disabled provider card +// ═══════════════════════════════════════════════════════════════════════════ + +class _DisabledProviderItem extends StatelessWidget { + final String providerId; + final _LyricsProviderInfo info; + final VoidCallback onToggle; + + const _DisabledProviderItem({ + super.key, + required this.providerId, + required this.info, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.03), + colorScheme.surface, + ) + : colorScheme.surfaceContainerLow; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Opacity( + opacity: 0.6, + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onToggle, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Empty space aligned with numbered badge + const SizedBox(width: 28), + const SizedBox(width: 16), + // Icon (muted) + Icon(info.icon, color: colorScheme.outline), + const SizedBox(width: 12), + // Name + description + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurfaceVariant, + ), + ), + Text( + info.description, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ), + // Switch + SizedBox( + height: 32, + child: FittedBox( + child: Switch( + value: false, + onChanged: (_) => onToggle(), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Provider info model +// ═══════════════════════════════════════════════════════════════════════════ + +class _LyricsProviderInfo { + final String name; + final String description; + final IconData icon; + + const _LyricsProviderInfo({ + required this.name, + required this.description, + required this.icon, + }); +} diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c75bcfe3..ca4f22b0 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -56,6 +57,7 @@ class _TrackMetadataScreenState extends ConsumerState { String? _rawLyrics; // Raw LRC with timestamps for embedding bool _lyricsLoading = false; String? _lyricsError; + String? _lyricsSource; bool _showTitleInAppBar = false; bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _isEmbedding = false; // Track embed operation in progress @@ -69,6 +71,11 @@ class _TrackMetadataScreenState extends ConsumerState { r'^\[\d{2}:\d{2}\.\d{2,3}\]', ); static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); + static final RegExp _lrcInlineTimestampPattern = RegExp( + r'<\d{2}:\d{2}\.\d{2,3}>', + ); + static final RegExp _lrcSpeakerPrefixPattern = RegExp(r'^(v1|v2):\s*'); + static final RegExp _lrcBackgroundLinePattern = RegExp(r'^\[bg:(.*)\]$'); static const List _months = [ 'Jan', 'Feb', @@ -1339,6 +1346,16 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], ), + if (_lyricsSource != null && _lyricsSource!.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Source: ${_lyricsSource!}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 12), if (_lyricsLoading) @@ -1460,6 +1477,7 @@ class _TrackMetadataScreenState extends ConsumerState { _lyricsLoading = true; _lyricsError = null; _isInstrumental = false; + _lyricsSource = null; }); try { @@ -1468,20 +1486,31 @@ class _TrackMetadataScreenState extends ConsumerState { // First, check if lyrics are embedded in the file if (_fileExists) { - final embeddedResult = await PlatformBridge.getLyricsLRC( - '', - trackName, - artistName, - filePath: cleanFilePath, - durationMs: 0, - ).timeout(const Duration(seconds: 5), onTimeout: () => ''); + final embeddedResult = + await PlatformBridge.getLyricsLRCWithSource( + '', + trackName, + artistName, + filePath: cleanFilePath, + durationMs: 0, + ).timeout( + const Duration(seconds: 5), + onTimeout: () => {'lyrics': '', 'source': ''}, + ); - if (embeddedResult.isNotEmpty) { + final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? ''; + final embeddedSource = embeddedResult['source']?.toString() ?? ''; + + if (embeddedLyrics.isNotEmpty) { // Lyrics found in file if (mounted) { - final cleanLyrics = _cleanLrcForDisplay(embeddedResult); + final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics); setState(() { _lyrics = cleanLyrics; + _rawLyrics = embeddedLyrics; + _lyricsSource = embeddedSource.isNotEmpty + ? embeddedSource + : 'Embedded'; _lyricsEmbedded = true; _lyricsLoading = false; }); @@ -1491,43 +1520,55 @@ class _TrackMetadataScreenState extends ConsumerState { } // No embedded lyrics, fetch from online - final result = await PlatformBridge.getLyricsLRC( + final result = await PlatformBridge.getLyricsLRCWithSource( _spotifyId ?? '', trackName, artistName, filePath: null, // Don't check file again durationMs: durationMs, - ).timeout(const Duration(seconds: 20), onTimeout: () => ''); + ).timeout(const Duration(seconds: 20)); + + final lrcText = result['lyrics']?.toString() ?? ''; + final source = result['source']?.toString() ?? ''; + final instrumental = + (result['instrumental'] as bool? ?? false) || + lrcText == '[instrumental:true]'; if (mounted) { // Check for instrumental marker - if (result == '[instrumental:true]') { + if (instrumental) { setState(() { _isInstrumental = true; + _lyricsSource = source.isNotEmpty ? source : null; _lyricsLoading = false; }); - } else if (result.isEmpty) { + } else if (lrcText.isEmpty) { setState(() { _lyricsError = context.l10n.trackLyricsNotAvailable; _lyricsLoading = false; }); } else { - final cleanLyrics = _cleanLrcForDisplay(result); + final cleanLyrics = _cleanLrcForDisplay(lrcText); setState(() { _lyrics = cleanLyrics; - _rawLyrics = result; // Keep raw LRC with timestamps for embedding + _rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding + _lyricsSource = source.isNotEmpty ? source : null; _lyricsEmbedded = false; // Lyrics from online, not embedded _lyricsLoading = false; }); } } + } on TimeoutException { + if (mounted) { + setState(() { + _lyricsError = context.l10n.trackLyricsTimeout; + _lyricsLoading = false; + }); + } } catch (e) { if (mounted) { - final errorMsg = e.toString().contains('TimeoutException') - ? context.l10n.trackLyricsTimeout - : context.l10n.trackLyricsLoadFailed; setState(() { - _lyricsError = errorMsg; + _lyricsError = context.l10n.trackLyricsLoadFailed; _lyricsLoading = false; }); } @@ -2213,17 +2254,28 @@ class _TrackMetadataScreenState extends ConsumerState { final cleanLines = []; for (final line in lines) { - final trimmedLine = line.trim(); + var cleaned = line.trim(); // Skip metadata tags - if (_lrcMetadataPattern.hasMatch(trimmedLine)) { + if (_lrcMetadataPattern.hasMatch(cleaned) && + !_lrcBackgroundLinePattern.hasMatch(cleaned)) { continue; } - // Remove timestamp and clean up - final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim(); - if (cleanLine.isNotEmpty) { - cleanLines.add(cleanLine); + // Convert [bg:...] wrapper to a plain secondary vocal line. + final bgMatch = _lrcBackgroundLinePattern.firstMatch(cleaned); + if (bgMatch != null) { + cleaned = bgMatch.group(1)?.trim() ?? ''; + } + + // Remove line timestamp, inline word-by-word timestamps, and speaker prefix. + cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); + cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); + cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); + cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); + + if (cleaned.isNotEmpty) { + cleanLines.add(cleaned); } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b879bf5d..f2351aeb 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -276,6 +276,23 @@ class PlatformBridge { return result as String; } + static Future> getLyricsLRCWithSource( + String spotifyId, + String trackName, + String artistName, { + String? filePath, + int durationMs = 0, + }) async { + final result = await _channel.invokeMethod('getLyricsLRCWithSource', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'file_path': filePath ?? '', + 'duration_ms': durationMs, + }); + return jsonDecode(result as String) as Map; + } + static Future> embedLyricsToFile( String filePath, String lyrics, @@ -332,6 +349,47 @@ 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/pubspec.yaml b/pubspec.yaml index 948feea3..53a9491b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.7+81 +version: 3.6.8+82 environment: sdk: ^3.10.0 diff --git a/site/docs.html b/site/docs.html new file mode 100644 index 00000000..2ad5c7f6 --- /dev/null +++ b/site/docs.html @@ -0,0 +1,5871 @@ + + + + + + Documentation - SpotiFLAC Mobile + + + + + + + + + + + + +
+ + + +
+ + + +
+ + +
+
+
+

Sections

+ +
+
+
+ +
+ + +
+
+

SpotiFLAC Extension Development Guide

+

A complete guide for creating SpotiFLAC extensions.

+

Table of Contents

+
    +
  1. Introduction
  2. +
  3. Extension Structure
  4. +
  5. Manifest File + +
  6. +
  7. Main Script
  8. +
  9. API Reference
  10. +
  11. Extension Examples
  12. +
  13. Packaging & Distribution + +
  14. +
  15. Troubleshooting
  16. +
  17. Technical Details & Behavior + +
  18. +
  19. Tips & Best Practices
  20. +
  21. Authentication API + +
  22. +
  23. Data Schema Reference
  24. +
+
+

Introduction

+

SpotiFLAC extensions allow you to add:

+
    +
  • 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

+
    +
  • Basic JavaScript knowledge
  • +
  • Text editor (VS Code, Notepad++, etc.)
  • +
  • Tool for creating ZIP files
  • +
+
+

Extension Structure

+

An extension is a ZIP file with the .spotiflac-ext extension containing:

+
my-extension.spotiflac-ext (ZIP)
+├── manifest.json      # Required: Metadata and configuration
+├── index.js          # Required: Main JavaScript code
+└── icon.png          # Optional: Extension icon (PNG, 128x128 recommended)
+
+
+

Manifest File

+

The manifest.json file contains extension metadata and configuration.

+

Complete Manifest Example

+
{
+  "name": "my-music-provider",
+  "displayName": "My Music Provider",
+  "version": "1.0.0",
+  "description": "Extension for downloading from MyMusic service",
+  "author": "Your Name",
+  "homepage": "https://github.com/username/my-extension",
+  "icon": "icon.png",
+
+  "permissions": {
+    "network": ["api.mymusic.com", "cdn.mymusic.com"],
+    "storage": true,
+    "file": true
+  },
+
+  "type": ["metadata_provider", "download_provider"],
+  
+  "skipMetadataEnrichment": false,
+  "skipBuiltInFallback": false,
+
+  "qualityOptions": [
+    {
+      "id": "LOSSLESS",
+      "label": "FLAC Lossless",
+      "description": "16-bit / 44.1kHz"
+    },
+    {
+      "id": "MP3_320",
+      "label": "MP3 320kbps",
+      "description": "High quality MP3"
+    },
+    {
+      "id": "OPUS_128",
+      "label": "Opus 128kbps",
+      "description": "Efficient audio codec"
+    }
+  ],
+
+  "settings": [
+    {
+      "key": "apiKey",
+      "label": "API Key",
+      "type": "string",
+      "description": "API key from MyMusic",
+      "required": true
+    },
+    {
+      "key": "quality",
+      "label": "Audio Quality",
+      "type": "select",
+      "options": ["LOSSLESS", "HIGH", "NORMAL"],
+      "default": "LOSSLESS"
+    },
+    {
+      "key": "enableCache",
+      "label": "Enable Cache",
+      "type": "boolean",
+      "default": true
+    }
+  ]
+}
+
+

Manifest Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
namestringYesUnique extension ID (lowercase, no spaces)
displayNamestringYesDisplay name for the extension
versionstringYesVersion (format: x.y.z)
descriptionstringYesShort description
authorstringYesCreator name
homepagestringNoHomepage/repository URL
iconstringNoIcon filename (e.g., "icon.png")
permissionsobjectYesAccess rights definition (network, storage)
typearrayYesExtension type (metadata_provider, download_provider, lyrics_provider)
settingsarrayNoUser configuration
qualityOptionsarrayNoCustom quality options for download providers (see below)
skipMetadataEnrichmentbooleanNoIf true, skip metadata enrichment from Deezer/Spotify (use metadata from extension)
skipBuiltInFallbackbooleanNoIf true, don't fallback to built-in providers (Tidal/Qobuz/Amazon) when extension download fails
minAppVersionstringNoMinimum SpotiFLAC version required (e.g., "1.0.0")
searchBehaviorobjectNoCustom search behavior configuration (see below)
urlHandlerobjectNoCustom URL handling configuration (see below)
trackMatchingobjectNoCustom track matching configuration (see below)
postProcessingobjectNoPost-processing hooks configuration (see below)
+

Quality Options

+

For download provider extensions, you can define custom quality options that will be shown in the quality picker UI. This is useful when your service offers different formats than the built-in providers (e.g., YouTube offers MP3/Opus instead of FLAC).

+
"qualityOptions": [
+  {
+    "id": "MP3_320",
+    "label": "MP3 320kbps",
+    "description": "High quality MP3"
+  },
+  {
+    "id": "OPUS_128",
+    "label": "Opus 128kbps",
+    "description": "Efficient audio codec"
+  },
+  {
+    "id": "AAC_256",
+    "label": "AAC 256kbps",
+    "description": "Apple audio format"
+  }
+]
+
+

Quality Option Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesUnique identifier passed to download function
labelstringYesDisplay name shown in the UI
descriptionstringNoAdditional info (e.g., bitrate, format)
settingsarrayNoQuality-specific settings (see below)
+

If qualityOptions is not specified, a default "Default Quality" option will be shown.

+

Quality-Specific Settings

+

Each quality option can have its own settings. This is useful when different quality tiers require different API configurations (e.g., different endpoints, API keys, or parameters).

+
"qualityOptions": [
+  {
+    "id": "PREMIUM_FLAC",
+    "label": "Premium FLAC",
+    "description": "24-bit Hi-Res (requires premium)",
+    "settings": [
+      {
+        "key": "premium_api_key",
+        "type": "string",
+        "label": "Premium API Key",
+        "description": "API key for premium tier access",
+        "required": true,
+        "secret": true
+      },
+      {
+        "key": "premium_endpoint",
+        "type": "string",
+        "label": "Premium Endpoint",
+        "default": "https://api.example.com/premium/stream"
+      }
+    ]
+  },
+  {
+    "id": "FREE_MP3",
+    "label": "Free MP3",
+    "description": "128kbps (free tier)",
+    "settings": [
+      {
+        "key": "free_endpoint",
+        "type": "string",
+        "label": "Free Endpoint",
+        "default": "https://api.example.com/free/stream"
+      }
+    ]
+  }
+]
+
+

In your extension code, access quality-specific settings like this:

+
function download(trackId, quality, outputPath, progressCallback) {
+  // Get quality-specific settings
+  const qualitySettings = settings.qualitySettings?.[quality] || {};
+  
+  let endpoint;
+  let apiKey;
+  
+  if (quality === 'PREMIUM_FLAC') {
+    endpoint = qualitySettings.premium_endpoint || 'https://api.example.com/premium/stream';
+    apiKey = qualitySettings.premium_api_key;
+    if (!apiKey) {
+      return { success: false, error: 'Premium API key required', error_type: 'auth_error' };
+    }
+  } else {
+    endpoint = qualitySettings.free_endpoint || 'https://api.example.com/free/stream';
+    apiKey = settings.api_key; // Use global API key for free tier
+  }
+  
+  // ... download logic using endpoint and apiKey
+}
+
+

Quality-Specific Setting Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
keystringYesSetting key (accessed via settings.qualitySettings[quality][key])
typestringYesstring, number, boolean, or select
labelstringYesDisplay name in settings UI
descriptionstringNoHelp text for the setting
requiredbooleanNoWhether the setting is required
secretbooleanNoIf true, input will be masked (for API keys)
defaultanyNoDefault value
optionsarrayNoOptions for select type
+

Permissions

+

Extensions must declare the resources they need:

+
"permissions": {
+  "network": [
+    "api.example.com",    // HTTP access to specific domain
+    "*.example.com"       // Wildcard subdomain
+  ],
+  "storage": true,        // Storage API access (for caching, settings)
+  "file": true            // File API access (for downloads, file operations)
+}
+
+

Permission Types:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
PermissionTypeDescription
networkarrayList of allowed domains for HTTP requests
storagebooleanAccess to key-value storage API
filebooleanAccess to file operations (read, write, download)
+

Important Notes:

+
    +
  • Only declared domains can be accessed via HTTP
  • +
  • Requests to other domains will be blocked
  • +
  • File operations are sandboxed to extension's data directory
  • +
  • Absolute paths are blocked for security (only relative paths allowed)
  • +
  • Download providers should set file: true to save downloaded files
  • +
+

Extension Types

+

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

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

Settings

+

Define user-configurable settings:

+
"settings": [
+  {
+    "key": "username",
+    "label": "Username",
+    "type": "string",
+    "description": "Your account username",
+    "required": true
+  },
+  {
+    "key": "region",
+    "label": "Region",
+    "type": "select",
+    "options": ["ID", "US", "JP", "UK"],
+    "default": "ID"
+  },
+  {
+    "key": "debug",
+    "label": "Debug Mode",
+    "type": "boolean",
+    "default": false
+  },
+  {
+    "key": "maxRetries",
+    "label": "Max Retries",
+    "type": "number",
+    "default": 3
+  }
+]
+
+

Setting Types:

+
    +
  • string: Text input
  • +
  • number: Number input
  • +
  • boolean: On/off toggle
  • +
  • select: Dropdown selection (requires options)
  • +
+

Setting Fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
keystringYesUnique setting key (used in code)
labelstringYesDisplay name in settings UI
typestringYesstring, number, boolean, or select
descriptionstringNoHelp text for the setting
requiredbooleanNoWhether the setting is required
secretbooleanNoIf true, input will be masked (for passwords/API keys)
defaultanyNoDefault value
optionsarrayNoOptions for select type
+

Button Setting Type

+

The button type allows extensions to trigger JavaScript functions directly from the settings page. This is useful for actions like OAuth login, clearing cache, or running maintenance tasks.

+
"settings": [
+  {
+    "key": "login_button",
+    "label": "Login to Service",
+    "type": "button",
+    "description": "Click to authenticate with your account",
+    "action": "startLogin"
+  },
+  {
+    "key": "clear_cache",
+    "label": "Clear Cache",
+    "type": "button",
+    "description": "Remove all cached data",
+    "action": "clearCache"
+  }
+]
+
+

Button-specific fields:

+ + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
actionstringYesName of the JavaScript function to call
+

Implementing button actions in your extension:

+
// In your extension's index.js
+function startLogin() {
+  // Start OAuth flow
+  auth.startOAuthWithPKCE({
+    authUrl: "https://accounts.example.com/authorize",
+    tokenUrl: "https://accounts.example.com/token",
+    clientId: settings.clientId,
+    scopes: ["streaming", "user-read-private"],
+    redirectUri: "spotiflac://auth/callback"
+  });
+  
+  return { success: true, message: "Opening login page..." };
+}
+
+function clearCache() {
+  storage.clear();
+  return { success: true, message: "Cache cleared!" };
+}
+
+// Register the action functions
+registerExtension({
+  initialize: initialize,
+  cleanup: cleanup,
+  startLogin: startLogin,     // Button action
+  clearCache: clearCache,     // Button action
+  // ... other functions
+});
+
+

Return format for button actions:

+
// Success
+{ success: true, message: "Optional success message" }
+
+// Error
+{ success: false, error: "Error description" }
+
+

Example with secret field (for API keys/passwords):

+
"settings": [
+  {
+    "key": "api_key",
+    "label": "API Key",
+    "type": "string",
+    "description": "Your API key from the service",
+    "required": true,
+    "secret": true
+  }
+]
+
+

Custom Search Behavior

+

Extensions can provide custom search functionality (e.g., search YouTube directly):

+
"searchBehavior": {
+  "enabled": true,
+  "placeholder": "Search YouTube...",
+  "primary": false,
+  "icon": "youtube.png",
+  "thumbnailRatio": "wide",
+  "thumbnailWidth": 100,
+  "thumbnailHeight": 56
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
enabledbooleanWhether extension provides custom search
placeholderstringPlaceholder text for search box
primarybooleanIf true, show as primary search tab
iconstringIcon for search tab
thumbnailRatiostringThumbnail aspect ratio preset (see below)
thumbnailWidthnumberCustom thumbnail width in pixels (optional)
thumbnailHeightnumberCustom thumbnail height in pixels (optional)
+

Thumbnail Ratio Presets

+

The thumbnailRatio field controls the aspect ratio of track thumbnails in search results. This is useful when your source uses different thumbnail dimensions than standard album art.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ValueAspect RatioUse Case
"square"1:1Album art, Spotify, Deezer (default)
"wide"16:9YouTube, video platforms
"portrait"2:3Poster-style, vertical thumbnails
+

Example for YouTube-style thumbnails:

+
"searchBehavior": {
+  "enabled": true,
+  "placeholder": "Search YouTube...",
+  "thumbnailRatio": "wide"
+}
+
+

Custom dimensions (overrides ratio preset):

+
"searchBehavior": {
+  "enabled": true,
+  "thumbnailWidth": 120,
+  "thumbnailHeight": 68
+}
+
+

When enabled, implement the customSearch function in your extension:

+
function customSearch(query, options) {
+  // Search your platform
+  const results = http.get(`https://api.example.com/search?q=${encodeURIComponent(query)}`);
+  // Return array of track objects
+  return JSON.parse(results.body).tracks.map(t => ({
+    id: t.id,
+    name: t.title,
+    artists: t.artist,
+    album_name: t.album,
+    duration_ms: t.duration * 1000,
+    images: t.thumbnail  // Thumbnail URL (will use thumbnailRatio for display)
+  }));
+}
+
+

Note: The images field in the returned track objects will be displayed using the thumbnailRatio setting from your manifest. For YouTube-style results, use "thumbnailRatio": "wide" to display 16:9 thumbnails.

+

Custom URL Handler

+

Extensions can register custom URL patterns to handle links from platforms like YouTube Music, SoundCloud, etc. When a user pastes or shares a URL that matches your pattern, SpotiFLAC will call your extension to handle it.

+
"urlHandler": {
+  "enabled": true,
+  "patterns": [
+    "music.youtube.com",
+    "youtube.com/watch",
+    "youtu.be"
+  ]
+}
+
+ + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
enabledbooleanWhether extension handles custom URLs
patternsarrayURL patterns to match (domain or path fragments)
+

Example patterns for common platforms:

+
// YouTube Music
+"patterns": ["music.youtube.com", "youtube.com/watch", "youtu.be"]
+
+// SoundCloud
+"patterns": ["soundcloud.com"]
+
+// Bandcamp
+"patterns": ["bandcamp.com"]
+
+

When enabled, implement the handleURL function in your extension:

+
/**
+ * Handle a URL from the user
+ * @param {string} url - The full URL to handle
+ * @returns {Object} Track, Album, or Artist metadata
+ */
+function handleURL(url) {
+  // Parse the URL to determine content type
+  const urlType = detectUrlType(url);
+  
+  if (urlType === 'track') {
+    return handleTrackUrl(url);
+  } else if (urlType === 'album') {
+    return handleAlbumUrl(url);
+  } else if (urlType === 'artist') {
+    return handleArtistUrl(url);
+  }
+  
+  return {
+    success: false,
+    error: "Unsupported URL type"
+  };
+}
+
+// Return a single track
+function handleTrackUrl(url) {
+  const trackId = extractTrackId(url);
+  const data = fetchTrackData(trackId);
+  
+  return {
+    success: true,
+    type: "track",  // Optional, defaults to "track"
+    track: {
+      id: data.id,
+      name: data.title,
+      artists: data.artist,
+      album_name: data.album || "Unknown Album",
+      duration_ms: data.duration * 1000,
+      images: data.thumbnail
+    }
+  };
+}
+
+// Return an album with tracks
+function handleAlbumUrl(url) {
+  const albumId = extractAlbumId(url);
+  const data = fetchAlbumData(albumId);
+  
+  return {
+    success: true,
+    type: "album",
+    album: {
+      id: data.id,
+      name: data.title,
+      artists: data.artist,
+      release_date: data.releaseDate,
+      total_tracks: data.tracks.length,
+      images: data.cover,
+      album_type: data.type,  // "album", "single", "compilation"
+      tracks: data.tracks.map(t => ({
+        id: t.id,
+        name: t.title,
+        artists: t.artist,
+        album_name: data.title,
+        duration_ms: t.duration * 1000,
+        track_number: t.trackNumber,
+        disc_number: t.discNumber || 1,
+        isrc: t.isrc
+      }))
+    }
+  };
+}
+
+// Return an artist with albums
+function handleArtistUrl(url) {
+  const artistId = extractArtistId(url);
+  const data = fetchArtistData(artistId);
+  
+  return {
+    success: true,
+    type: "artist",
+    artist: {
+      id: data.id,
+      name: data.name,
+      image_url: data.picture,
+      albums: data.albums.map(a => ({
+        id: a.id,
+        name: a.title,
+        artists: data.name,
+        release_date: a.releaseDate,
+        total_tracks: a.trackCount,
+        images: a.cover,
+        album_type: a.type  // "album", "single", "compilation"
+      }))
+    }
+  };
+}
+
+

Return Types:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
TypeDescriptionRequired Fields
trackSingle tracktrack.id, track.name, track.artists
albumAlbum with tracksalbum.id, album.name, album.tracks[]
artistArtist with albumsartist.id, artist.name, artist.albums[]
+

Important: Don't forget to register the handleURL function:

+
registerExtension({
+  initialize: initialize,
+  cleanup: cleanup,
+  handleURL: handleURL,  // Add this!
+  // ... other functions
+});
+
+

URL Handler Flow:

+
    +
  1. User pastes/shares a URL (e.g., https://music.youtube.com/watch?v=abc123)
  2. +
  3. SpotiFLAC checks if any extension's patterns match the URL
  4. +
  5. If matched, calls the extension's handleURL(url) function
  6. +
  7. Extension returns track/album/artist metadata based on type field
  8. +
  9. SpotiFLAC navigates to appropriate screen (track detail, album, or artist page)
  10. +
+

Album & Playlist Functions (v3.0.1+)

+

Extensions can provide album/playlist tracks for the search results. When your customSearch returns items with item_type: "album" or item_type: "playlist", users can tap on them to view the track list.

+

Manifest requirements:

+
{
+  "minAppVersion": "3.0.1",
+  "type": ["metadata_provider"]
+}
+
+

Search result with album/playlist items:

+
function customSearch(query, options) {
+  const results = searchAPI(query);
+  
+  return results.map(item => {
+    if (item.type === 'track') {
+      return {
+        id: item.id,
+        name: item.title,
+        artists: item.artist,
+        album_name: item.album,
+        duration_ms: item.duration * 1000,
+        cover_url: item.thumbnail,
+        item_type: "track"  // Optional, default
+      };
+    } else if (item.type === 'album' || item.type === 'ep' || item.type === 'single') {
+      return {
+        id: item.id,              // Album/browse ID
+        name: item.title,
+        artists: item.artist,
+        album_name: item.title,   // Same as name for albums
+        album_type: item.type,    // "album", "ep", "single", "playlist"
+        release_date: item.year,
+        cover_url: item.thumbnail,
+        item_type: "album"        // REQUIRED for albums
+      };
+    } else if (item.type === 'playlist') {
+      return {
+        id: item.id,              // Playlist ID
+        name: item.title,
+        artists: item.owner,      // Playlist owner
+        album_name: item.title,
+        album_type: "playlist",
+        cover_url: item.thumbnail,
+        item_type: "playlist"     // REQUIRED for playlists
+      };
+    }
+  });
+}
+
+

Implement getAlbum and getPlaylist functions:

+
/**
+ * Fetch album tracks by ID
+ * @param {string} albumId - Album ID from search result
+ * @returns {Object} Album with tracks array
+ */
+function getAlbum(albumId) {
+  const data = fetchAlbumData(albumId);
+  
+  return {
+    id: albumId,
+    name: data.title,
+    artists: data.artist,
+    cover_url: data.thumbnail,
+    release_date: data.year,
+    total_tracks: data.tracks.length,
+    album_type: data.type,  // "album", "ep", "single"
+    tracks: data.tracks.map(t => ({
+      id: t.id,
+      name: t.title,
+      artists: t.artist,
+      album_name: data.title,
+      duration_ms: t.duration * 1000,
+      cover_url: t.thumbnail || data.thumbnail,
+      track_number: t.trackNumber,
+      provider_id: "your-extension-id"
+    })),
+    provider_id: "your-extension-id"
+  };
+}
+
+/**
+ * Fetch playlist tracks by ID
+ * @param {string} playlistId - Playlist ID from search result
+ * @returns {Object} Playlist with tracks array
+ */
+function getPlaylist(playlistId) {
+  const data = fetchPlaylistData(playlistId);
+  
+  return {
+    id: playlistId,
+    name: data.title,
+    owner: data.owner,
+    cover_url: data.thumbnail,
+    total_tracks: data.tracks.length,
+    tracks: data.tracks.map(t => ({
+      id: t.id,
+      name: t.title,
+      artists: t.artist,
+      album_name: t.album || data.title,
+      duration_ms: t.duration * 1000,
+      cover_url: t.thumbnail,
+      provider_id: "your-extension-id"
+    })),
+    provider_id: "your-extension-id"
+  };
+}
+
+// Register functions
+registerExtension({
+  initialize: initialize,
+  customSearch: customSearch,
+  getAlbum: getAlbum,       // Required for album support
+  getPlaylist: getPlaylist, // Required for playlist support
+  // ... other functions
+});
+
+

Return schema for getAlbum:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesAlbum ID
namestringYesAlbum title
artistsstringYesArtist name(s)
cover_urlstringNoAlbum artwork URL
release_datestringNoRelease date (YYYY or YYYY-MM-DD)
total_tracksnumberNoNumber of tracks
album_typestringNo"album", "ep", "single"
tracksarrayYesArray of track objects
provider_idstringYesYour extension ID
+

Return schema for getPlaylist:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesPlaylist ID
namestringYesPlaylist title
ownerstringNoPlaylist owner/creator
cover_urlstringNoPlaylist cover URL
total_tracksnumberNoNumber of tracks
tracksarrayYesArray of track objects
provider_idstringYesYour extension ID
+

Flow:

+
    +
  1. User searches → customSearch() returns tracks + albums/playlists with item_type
  2. +
  3. Search results show mixed items (tracks show duration, albums show "Album • Artist • Year")
  4. +
  5. User taps album/playlist → SpotiFLAC calls getAlbum(id) or getPlaylist(id)
  6. +
  7. Extension fetches and returns track list
  8. +
  9. SpotiFLAC displays tracks, user can download them
  10. +
+

Artist Support

+

Extensions can support artist pages by returning artist items from customSearch() and implementing getArtist():

+

Return artist items from customSearch:

+
function customSearch(query) {
+  const results = searchAPI(query);
+  
+  return results.map(item => {
+    if (item.type === "artist") {
+      return {
+        id: item.id,
+        name: item.name,
+        artists: item.name,      // Artist name in artists field for consistency
+        cover_url: item.thumbnail,
+        item_type: "artist"      // REQUIRED for artist items
+      };
+    }
+    // ... handle tracks, albums, playlists
+  });
+}
+
+

Implement getArtist function:

+
/**
+ * Fetch artist info and albums by ID
+ * @param {string} artistId - Artist ID from search result
+ * @returns {Object} Artist info with albums array
+ */
+function getArtist(artistId) {
+  const data = fetchArtistData(artistId);
+  
+  return {
+    id: artistId,
+    name: data.name,
+    image_url: data.thumbnail,
+    albums: data.albums.map(album => ({
+      id: album.id,
+      name: album.title,
+      artists: data.name,
+      cover_url: album.thumbnail,
+      release_date: album.year,
+      total_tracks: album.trackCount || 0,
+      album_type: album.type || "album",  // "album", "ep", "single"
+      provider_id: "your-extension-id"
+    })),
+    provider_id: "your-extension-id"
+  };
+}
+
+// Register function
+registerExtension({
+  // ... other functions
+  getArtist: getArtist,  // Required for artist support
+});
+
+

Return schema for getArtist:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesArtist ID
namestringYesArtist name
image_urlstringNoArtist image URL
albumsarrayYesArray of album objects (see album schema)
provider_idstringYesYour extension ID
+

Album object schema (within albums array):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesAlbum ID
namestringYesAlbum title
artistsstringNoArtist name(s)
cover_urlstringNoAlbum artwork URL
release_datestringNoRelease date (YYYY or YYYY-MM-DD)
total_tracksnumberNoNumber of tracks
album_typestringNo"album", "ep", "single", "compilation"
provider_idstringYesYour extension ID
+

Home Feed Support

+

Extensions can provide a personalized home feed with sections containing tracks, albums, playlists, and artists. This is useful for music streaming service extensions that have personalized recommendations.

+

Manifest configuration:

+
{
+  "name": "my-music-extension",
+  "version": "1.0.0",
+  "capabilities": {
+    "homeFeed": true,
+    "browseCategories": true
+  }
+}
+
+ + + + + + + + + + + + + + + + + +
CapabilityDescription
homeFeedExtension provides personalized home feed via getHomeFeed()
browseCategoriesExtension provides browse categories via getBrowseCategories()
+

Implement getHomeFeed function:

+
/**
+ * Fetch personalized home feed with sections
+ * @returns {Object} Home feed data with greeting and sections
+ */
+function getHomeFeed() {
+  // Fetch home data from your API
+  const response = http.get("https://api.example.com/home", {
+    headers: { "Authorization": "Bearer " + accessToken }
+  });
+  
+  if (!response.ok) {
+    return { success: false, error: "Failed to fetch home feed" };
+  }
+  
+  const data = JSON.parse(response.body);
+  
+  return {
+    success: true,
+    greeting: data.greeting || "Good morning",  // Time-based greeting
+    sections: data.sections.map(section => ({
+      uri: section.id,
+      title: section.title,
+      items: section.items.map(item => formatHomeFeedItem(item))
+    }))
+  };
+}
+
+/**
+ * Format a single item for home feed
+ */
+function formatHomeFeedItem(item) {
+  const result = {
+    id: item.id,
+    uri: item.uri,                    // e.g., "myservice:track:abc123"
+    type: item.type,                  // "track", "album", "playlist", "artist"
+    name: item.name,
+    artists: item.artistName || "",   // Artist name(s) for tracks/albums
+    description: item.description,    // For playlists
+    cover_url: item.imageUrl,
+    provider_id: "my-music-extension"
+  };
+  
+  // For tracks, include album info for "Go to Album" feature
+  if (item.type === "track" && item.album) {
+    result.album_id = item.album.id;
+    result.album_name = item.album.name;
+  }
+  
+  return result;
+}
+
+// Register function
+registerExtension({
+  // ... other functions
+  getHomeFeed: getHomeFeed
+});
+
+

Return schema for getHomeFeed:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
successbooleanYesWhether the request succeeded
errorstringNoError message if success is false
greetingstringNoTime-based greeting (e.g., "Good morning")
sectionsarrayYesArray of section objects
+

Section object schema:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
uristringNoSection identifier/URI
titlestringYesSection title (e.g., "Trending Songs", "Popular Artists")
itemsarrayYesArray of item objects
+

Item object schema:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeRequiredDescription
idstringYesItem ID (track/album/playlist/artist ID)
uristringNoFull URI (e.g., "spotify:track:abc123")
typestringYesItem type: "track", "album", "playlist", "artist", "station"
namestringYesItem name/title
artistsstringNoArtist name(s) - for tracks and albums
descriptionstringNoDescription - for playlists
cover_urlstringNoCover/artwork image URL
album_idstringNoAlbum ID - for tracks (enables "Go to Album")
album_namestringNoAlbum name - for tracks
provider_idstringYesYour extension ID
+

Getting timezone for time-based greeting:

+
+

Note: The Goja JavaScript engine may not support Intl.DateTimeFormat() properly and Date.getTimezoneOffset() may return 0. Use gobackend.getLocalTime() for accurate timezone detection. See Go Backend API for details.

+
+
function getHomeFeed() {
+  // Get user's timezone using gobackend API (recommended)
+  let timeZone = "UTC";
+  try {
+    const localTime = gobackend.getLocalTime();
+    if (localTime.timezone && localTime.timezone !== "Local") {
+      timeZone = localTime.timezone;
+    }
+  } catch (e) {
+    // Fallback to UTC
+  }
+  
+  // Use timezone in API request for proper greeting
+  const response = http.get("https://api.example.com/home?timezone=" + encodeURIComponent(timeZone));
+  // ...
+}
+
+// For time-based greeting, use local hour directly
+function getTimeBasedGreeting() {
+  const localTime = gobackend.getLocalTime();
+  const hour = localTime.hour;
+  
+  if (hour >= 5 && hour < 12) return "Good morning";
+  if (hour >= 12 && hour < 17) return "Good afternoon";
+  if (hour >= 17 && hour < 21) return "Good evening";
+  return "Good night";
+}
+
+

Implement getBrowseCategories function (optional):

+
/**
+ * Fetch browse categories (genres, moods, etc.)
+ * @returns {Object} Categories data
+ */
+function getBrowseCategories() {
+  const response = http.get("https://api.example.com/browse/categories");
+  
+  if (!response.ok) {
+    return { success: false, error: "Failed to fetch categories" };
+  }
+  
+  const data = JSON.parse(response.body);
+  
+  return {
+    success: true,
+    categories: data.categories.map(cat => ({
+      id: cat.id,
+      name: cat.name,
+      icon_url: cat.imageUrl
+    }))
+  };
+}
+
+registerExtension({
+  // ... other functions
+  getHomeFeed: getHomeFeed,
+  getBrowseCategories: getBrowseCategories
+});
+
+

UI Behavior:

+

When an extension has homeFeed capability enabled:

+
    +
  1. The home screen shows personalized sections instead of the default placeholder
  2. +
  3. Each section displays horizontally scrollable items (tracks, albums, etc.)
  4. +
  5. Tapping items navigates to appropriate screens: +
      +
    • Track: Shows bottom sheet with "Download" and "Go to Album" options
    • +
    • Album: Opens album screen with track list
    • +
    • Playlist: Opens playlist screen with track list
    • +
    • Artist: Opens artist screen with discography
    • +
    +
  6. +
  7. Pull-to-refresh reloads the home feed
  8. +
  9. Home feed is cached for 5 minutes to reduce API calls
  10. +
+

Track Enrichment

+

Extensions can enrich track metadata before download using enrichTrack(). This is useful for:

+
    +
  • Adding ISRC codes from external APIs (e.g., Odesli/song.link)
  • +
  • Getting links to other streaming services for fallback downloads
  • +
  • Enriching metadata with additional info
  • +
+
/**
+ * Enrich track metadata before download
+ * @param {Object} track - Track object from search/album/playlist
+ * @returns {Object} Enriched track object
+ */
+function enrichTrack(track) {
+  if (!track || !track.id) {
+    return track;
+  }
+  
+  // Example: Use Odesli API to get ISRC and external links
+  const ytUrl = "https://music.youtube.com/watch?v=" + encodeURIComponent(track.id);
+  const odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(ytUrl);
+  
+  try {
+    const res = fetch(odesliUrl, { method: "GET" });
+    if (!res || !res.ok) {
+      return track;
+    }
+    
+    const data = res.json();
+    const enrichment = {};
+    
+    // Extract ISRC from entities
+    if (data.entitiesByUniqueId) {
+      for (const key of Object.keys(data.entitiesByUniqueId)) {
+        const entity = data.entitiesByUniqueId[key];
+        if (entity && entity.isrc) {
+          enrichment.isrc = entity.isrc;
+          break;
+        }
+      }
+    }
+    
+    // Extract external links for fallback downloads
+    if (data.linksByPlatform) {
+      enrichment.external_links = {};
+      
+      if (data.linksByPlatform.deezer) {
+        enrichment.external_links.deezer = data.linksByPlatform.deezer.url;
+        // Extract Deezer track ID
+        const match = data.linksByPlatform.deezer.url.match(/\/track\/(\d+)/);
+        if (match) enrichment.deezer_id = match[1];
+      }
+      if (data.linksByPlatform.tidal) {
+        enrichment.external_links.tidal = data.linksByPlatform.tidal.url;
+      }
+      if (data.linksByPlatform.spotify) {
+        enrichment.external_links.spotify = data.linksByPlatform.spotify.url;
+      }
+    }
+    
+    return Object.assign({}, track, enrichment);
+  } catch (e) {
+    log.error("enrichTrack failed", e);
+    return track;
+  }
+}
+
+// Register function
+registerExtension({
+  // ... other functions
+  enrichTrack: enrichTrack,  // Optional: enrich tracks before download
+});
+
+

Enriched track fields:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
isrcstringInternational Standard Recording Code
tidal_idstringTidal track ID for direct download (skip search)
qobuz_idstringQobuz track ID for direct download (skip search)
deezer_idstringDeezer track ID for fallback
spotify_idstringSpotify track ID for fallback
external_linksobjectMap of service → URL
external_links.tidalstringTidal track URL
external_links.qobuzstringQobuz track URL
external_links.deezerstringDeezer track URL
external_links.spotifystringSpotify track URL
external_links.applestringApple Music track URL
+

How enrichment enables high-quality downloads:

+

When your extension provides tidal_id or qobuz_id, SpotiFLAC can download lossless audio without searching. This is the recommended approach for extensions that don't provide their own audio source.

+
Extension Search (YouTube Music, SoundCloud, etc.)
+       │
+       ▼
+   enrichTrack() called before download
+       │
+       ▼
+   Odesli API returns: tidal_id, qobuz_id, isrc
+       │
+       ▼
+   SpotiFLAC downloads from Tidal/Qobuz using direct ID
+       │
+       ▼
+   High-quality FLAC/MQA audio (no search needed!)
+
+

Important: This enrichment flow only applies to extension tracks. Normal Spotify/Deezer downloads are not affected and continue using their standard flow.

+

Complete enrichTrack example with all service IDs:

+
function enrichTrack(track) {
+  if (!track || !track.id) return track;
+  
+  // Build URL for Odesli lookup (adjust for your service)
+  const sourceUrl = "https://your-service.com/track/" + track.id;
+  const odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(sourceUrl);
+  
+  try {
+    const res = fetch(odesliUrl, { method: "GET" });
+    if (!res || !res.ok) return track;
+    
+    const data = res.json();
+    const enrichment = { external_links: {} };
+    
+    // Extract ISRC (used for search fallback)
+    if (data.entitiesByUniqueId) {
+      for (const key of Object.keys(data.entitiesByUniqueId)) {
+        const entity = data.entitiesByUniqueId[key];
+        if (entity && entity.isrc) {
+          enrichment.isrc = entity.isrc;
+          break;
+        }
+      }
+    }
+    
+    // Extract service-specific IDs for DIRECT download (no search!)
+    if (data.linksByPlatform) {
+      const links = data.linksByPlatform;
+      
+      // Tidal - enables lossless/MQA download
+      if (links.tidal && links.tidal.url) {
+        enrichment.external_links.tidal = links.tidal.url;
+        const match = links.tidal.url.match(/\/track\/(\d+)/);
+        if (match) enrichment.tidal_id = match[1];
+      }
+      
+      // Qobuz - enables Hi-Res FLAC download
+      if (links.qobuz && links.qobuz.url) {
+        enrichment.external_links.qobuz = links.qobuz.url;
+        const match = links.qobuz.url.match(/\/track\/(\d+)/);
+        if (match) enrichment.qobuz_id = match[1];
+      }
+      
+      // Deezer - enables FLAC download
+      if (links.deezer && links.deezer.url) {
+        enrichment.external_links.deezer = links.deezer.url;
+        const match = links.deezer.url.match(/\/track\/(\d+)/);
+        if (match) enrichment.deezer_id = match[1];
+      }
+      
+      // Spotify - for metadata/display
+      if (links.spotify && links.spotify.url) {
+        enrichment.external_links.spotify = links.spotify.url;
+        const match = links.spotify.url.match(/\/track\/([a-zA-Z0-9]+)/);
+        if (match) enrichment.spotify_id = match[1];
+      }
+    }
+    
+    return Object.assign({}, track, enrichment);
+  } catch (e) {
+    log.error("enrichTrack failed", e);
+    return track;
+  }
+}
+
+

Download priority with enrichment:

+
    +
  1. tidal_id → Direct Tidal download (highest priority, lossless/MQA)
  2. +
  3. qobuz_id → Direct Qobuz download (Hi-Res FLAC up to 24-bit/192kHz)
  4. +
  5. ISRC search → Search Tidal/Qobuz by ISRC code
  6. +
  7. Metadata search → Search by track name/artist (last resort)
  8. +
+

Custom Track Matching

+

Extensions can override the default ISRC-based track matching:

+
"trackMatching": {
+  "customMatching": true,
+  "strategy": "custom",
+  "durationTolerance": 5
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
customMatchingbooleanWhether extension handles matching
strategystring"isrc", "name", "duration", or "custom"
durationTolerancenumberTolerance in seconds for duration matching
+

When enabled, implement the matchTrack function:

+
function matchTrack(sourceTrack, candidates) {
+  // sourceTrack: { name, artists, duration_ms, isrc, ... }
+  // candidates: array of tracks from your search
+  
+  // Use built-in matching helpers
+  const normalizedSource = matching.normalizeString(sourceTrack.name);
+  
+  for (const candidate of candidates) {
+    const normalizedCandidate = matching.normalizeString(candidate.name);
+    const similarity = matching.compareStrings(normalizedSource, normalizedCandidate);
+    const durationMatch = matching.compareDuration(sourceTrack.duration_ms, candidate.duration_ms, 3000);
+    
+    if (similarity > 0.8 && durationMatch) {
+      return {
+        matched: true,
+        track_id: candidate.id,
+        confidence: similarity
+      };
+    }
+  }
+  
+  return { matched: false, reason: "No match found" };
+}
+
+

Post-Processing Hooks

+

Extensions can modify files after download (convert format, normalize audio, etc.):

+
"postProcessing": {
+  "enabled": true,
+  "hooks": [
+    {
+      "id": "convert_mp3",
+      "name": "Convert to MP3",
+      "description": "Convert FLAC to MP3 320kbps",
+      "defaultEnabled": false,
+      "supportedFormats": ["flac"]
+    },
+    {
+      "id": "normalize",
+      "name": "Normalize Audio",
+      "description": "Apply ReplayGain normalization",
+      "defaultEnabled": true,
+      "supportedFormats": ["flac", "mp3"]
+    }
+  ]
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
enabledbooleanWhether extension provides post-processing
hooksarrayList of available hooks
hooks[].idstringUnique hook identifier
hooks[].namestringDisplay name
hooks[].descriptionstringDescription
hooks[].defaultEnabledbooleanWhether enabled by default
hooks[].supportedFormatsarraySupported file formats
+

Implement the postProcess function:

+
function postProcess(filePath, metadata, hookId) {
+  if (hookId === 'convert_mp3') {
+    const outputPath = filePath.replace('.flac', '.mp3');
+    
+    // Use FFmpeg API
+    const result = ffmpeg.convert(filePath, outputPath, {
+      codec: 'libmp3lame',
+      bitrate: '320k'
+    });
+    
+    if (result.success) {
+      // Delete original file
+      file.delete(filePath);
+      return { success: true, new_file_path: outputPath };
+    }
+    return { success: false, error: result.error };
+  }
+  
+  if (hookId === 'normalize') {
+    // Apply ReplayGain
+    const result = ffmpeg.execute(`-i "${filePath}" -af "loudnorm" -y "${filePath}.tmp"`);
+    if (result.success) {
+      file.move(filePath + '.tmp', filePath);
+      return { success: true, new_file_path: filePath };
+    }
+    return { success: false, error: result.error };
+  }
+  
+  return { success: true, new_file_path: filePath };
+}
+
+

Post-Process API v2 (Recommended)

+

For SAF/scoped storage support, extensions can implement postProcessV2 (preferred). +If present, it will be called before postProcess.

+
function postProcessV2(input, metadata, hookId) {
+  // input: {
+  //   path: string (temp/local path, if available)
+  //   uri: string (content:// URI, if available)
+  //   name: string (file name)
+  //   mime_type: string
+  //   size: number (bytes)
+  //   is_saf: boolean
+  // }
+  const filePath = input.path || "";
+  if (!filePath) {
+    return { success: false, error: "no path provided" };
+  }
+  return postProcess(filePath, metadata, hookId);
+}
+
+

Notes:

+
    +
  • 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.

+

Basic Structure

+
// ============================================
+// Extension: My Music Provider
+// Version: 1.0.0
+// ============================================
+
+// Global variable to store settings
+let settings = {};
+
+// ============================================
+// LIFECYCLE HOOKS (Required)
+// ============================================
+
+/**
+ * Called when extension is loaded
+ * @param {Object} config - User settings
+ */
+function initialize(config) {
+  settings = config || {};
+  log.info("Extension initialized with settings:", settings);
+
+  // Validate required settings
+  if (!settings.apiKey) {
+    throw new Error("API Key is required");
+  }
+
+  return true;
+}
+
+/**
+ * Called when extension is unloaded
+ */
+function cleanup() {
+  log.info("Extension cleanup");
+  // Clean up resources if any
+}
+
+// ============================================
+// METADATA PROVIDER (Optional)
+// ============================================
+
+/**
+ * Search tracks by query
+ * @param {string} query - Search query
+ * @param {number} limit - Max results
+ * @returns {Array} Array of track objects
+ */
+function searchTracks(query, limit) {
+  log.debug("Searching:", query);
+
+  const response = http.get("https://api.mymusic.com/search", {
+    params: {
+      q: query,
+      type: "track",
+      limit: limit
+    },
+    headers: {
+      "Authorization": "Bearer " + settings.apiKey
+    }
+  });
+
+  if (!response.ok) {
+    log.error("Search failed:", response.status);
+    return [];
+  }
+
+  const data = JSON.parse(response.body);
+
+  // Transform to SpotiFLAC format
+  return data.tracks.map(track => ({
+    id: track.id,
+    name: track.title,
+    artists: track.artist.name,
+    album_name: track.album.title,
+    album_artist: track.album.artist.name,
+    isrc: track.isrc,
+    duration_ms: track.duration * 1000,
+    track_number: track.trackNumber,
+    disc_number: track.discNumber || 1,
+    release_date: track.album.releaseDate,
+    images: track.album.cover
+  }));
+}
+
+
+/**
+ * Get track detail by ID
+ * @param {string} trackId - Track ID
+ * @returns {Object} Track object
+ */
+function getTrack(trackId) {
+  const response = http.get("https://api.mymusic.com/tracks/" + trackId, {
+    headers: {
+      "Authorization": "Bearer " + settings.apiKey
+    }
+  });
+
+  if (!response.ok) {
+    return null;
+  }
+
+  const track = JSON.parse(response.body);
+
+  return {
+    id: track.id,
+    name: track.title,
+    artists: track.artist.name,
+    album_name: track.album.title,
+    album_artist: track.album.artist.name,
+    isrc: track.isrc,
+    duration_ms: track.duration * 1000,
+    track_number: track.trackNumber,
+    disc_number: track.discNumber || 1,
+    release_date: track.album.releaseDate,
+    images: track.album.cover
+  };
+}
+
+/**
+ * Get album detail by ID
+ * @param {string} albumId - Album ID
+ * @returns {Object} Album object with tracks
+ */
+function getAlbum(albumId) {
+  const response = http.get("https://api.mymusic.com/albums/" + albumId, {
+    headers: {
+      "Authorization": "Bearer " + settings.apiKey
+    }
+  });
+
+  if (!response.ok) {
+    return null;
+  }
+
+  const album = JSON.parse(response.body);
+
+  return {
+    id: album.id,
+    name: album.title,
+    artists: album.artist.name,
+    release_date: album.releaseDate,
+    total_tracks: album.trackCount,
+    images: album.cover,
+    tracks: album.tracks.map(track => ({
+      id: track.id,
+      name: track.title,
+      artists: track.artist.name,
+      album_name: album.title,
+      isrc: track.isrc,
+      duration_ms: track.duration * 1000,
+      track_number: track.trackNumber,
+      disc_number: track.discNumber || 1
+    }))
+  };
+}
+
+/**
+ * Get artist detail by ID
+ * @param {string} artistId - Artist ID
+ * @returns {Object} Artist object
+ */
+function getArtist(artistId) {
+  const response = http.get("https://api.mymusic.com/artists/" + artistId, {
+    headers: {
+      "Authorization": "Bearer " + settings.apiKey
+    }
+  });
+
+  if (!response.ok) {
+    return null;
+  }
+
+  const artist = JSON.parse(response.body);
+
+  return {
+    id: artist.id,
+    name: artist.name,
+    images: artist.picture,
+    albums: artist.albums.map(album => ({
+      id: album.id,
+      name: album.title,
+      release_date: album.releaseDate,
+      total_tracks: album.trackCount,
+      images: album.cover,
+      album_type: album.type
+    }))
+  };
+}
+
+/**
+ * Enrich track metadata before download (lazy enrichment hook)
+ * 
+ * This function is called by the runtime just before download starts.
+ * Use this to fetch expensive metadata (like real ISRC) that you don't
+ * want to fetch upfront when loading playlists/albums.
+ * 
+ * Benefits:
+ * - Playlists/albums load instantly without waiting for enrichment
+ * - Only tracks that are actually downloaded get enriched
+ * - Reduces API calls for tracks that are never downloaded
+ * 
+ * @param {Object} track - Track metadata object
+ * @param {string} track.id - Track ID
+ * @param {string} track.name - Track name
+ * @param {string} track.artists - Artist name(s)
+ * @param {string} track.isrc - Current ISRC (may be placeholder)
+ * @param {number} track.duration_ms - Duration in milliseconds
+ * @returns {Object} Enriched track metadata (or original if no enrichment needed)
+ * 
+ * @example
+ * function enrichTrack(track) {
+ *   // Only enrich if ISRC looks like a placeholder (e.g., Spotify ID)
+ *   if (track.isrc && track.isrc.length === 22) {
+ *     // Fetch real ISRC from external API
+ *     const realISRC = fetchRealISRC(track.id);
+ *     if (realISRC) {
+ *       track.isrc = realISRC;
+ *     }
+ *   }
+ *   return track;
+ * }
+ */
+function enrichTrack(track) {
+  // Example: Fetch real ISRC via SongLink -> Deezer
+  if (track.isrc && track.isrc.length === 22) {
+    // This looks like a Spotify ID, not a real ISRC
+    const deezerUrl = getDeezerUrlFromSongLink(track.id);
+    if (deezerUrl) {
+      const realISRC = getISRCFromDeezer(deezerUrl);
+      if (realISRC) {
+        log.info("Enriched ISRC:", track.isrc, "->", realISRC);
+        track.isrc = realISRC;
+      }
+    }
+  }
+  return track;
+}
+
+// ============================================
+// ODESLI (SONG.LINK) INTEGRATION EXAMPLE
+// ============================================
+// The Odesli API is useful for:
+// - Converting YouTube/SoundCloud tracks to ISRC
+// - Finding the same track on Deezer/Tidal/Spotify
+// - Enabling built-in service fallback for extensions that don't have ISRCs
+
+/**
+ * Example: Enrich YouTube Music tracks with ISRC via Odesli
+ * @param {Object} track - Track metadata from extension
+ * @returns {Object} Enriched track with ISRC and external links
+ */
+function enrichTrackWithOdesli(track) {
+  if (!track || !track.id) return track;
+  
+  // Build YouTube Music URL for Odesli lookup
+  var ytUrl = "https://music.youtube.com/watch?v=" + encodeURIComponent(track.id);
+  var odesliUrl = "https://api.song.link/v1-alpha.1/links?url=" + encodeURIComponent(ytUrl);
+  
+  try {
+    var res = fetch(odesliUrl, {
+      method: "GET",
+      headers: {
+        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+      }
+    });
+    
+    if (!res || !res.ok) return track;
+    
+    var data = res.json();
+    if (!data) return track;
+    
+    // Extract ISRC from entitiesByUniqueId
+    if (data.entitiesByUniqueId) {
+      var entities = data.entitiesByUniqueId;
+      var entityKeys = Object.keys(entities);
+      
+      for (var i = 0; i < entityKeys.length; i++) {
+        var entity = entities[entityKeys[i]];
+        if (entity && entity.isrc) {
+          track.isrc = entity.isrc;
+          log.info("enrichTrack: found ISRC", track.isrc);
+          break;
+        }
+      }
+    }
+    
+    // Extract links to other services (optional)
+    if (data.linksByPlatform) {
+      var links = data.linksByPlatform;
+      track.external_links = {};
+      
+      if (links.deezer && links.deezer.url) {
+        track.external_links.deezer = links.deezer.url;
+        // Extract Deezer track ID
+        var deezerMatch = links.deezer.url.match(/\/track\/(\d+)/);
+        if (deezerMatch) track.deezer_id = deezerMatch[1];
+      }
+      if (links.tidal && links.tidal.url) {
+        track.external_links.tidal = links.tidal.url;
+      }
+      if (links.spotify && links.spotify.url) {
+        track.external_links.spotify = links.spotify.url;
+      }
+    }
+    
+    return track;
+  } catch (e) {
+    log.error("enrichTrack: Odesli API error", String(e));
+    return track;
+  }
+}
+
+// Don't forget to add odesli.io/api.song.link to manifest permissions:
+// "permissions": {
+//   "network": ["api.song.link", "odesli.io", ...]
+// }
+
+
+// ============================================
+// DOWNLOAD PROVIDER (Optional)
+// ============================================
+
+/**
+ * Check if track is available for download
+ * @param {string} isrc - ISRC code
+ * @param {string} trackName - Track name (fallback)
+ * @param {string} artistName - Artist name (fallback)
+ * @returns {Object} Availability info
+ */
+function checkAvailability(isrc, trackName, artistName) {
+  // Search track by ISRC
+  let trackId = null;
+
+  if (isrc) {
+    const response = http.get("https://api.mymusic.com/search", {
+      params: { isrc: isrc },
+      headers: { "Authorization": "Bearer " + settings.apiKey }
+    });
+
+    if (response.ok) {
+      const data = JSON.parse(response.body);
+      if (data.tracks && data.tracks.length > 0) {
+        trackId = data.tracks[0].id;
+      }
+    }
+  }
+
+  // Fallback: search by name
+  if (!trackId) {
+    const query = trackName + " " + artistName;
+    const response = http.get("https://api.mymusic.com/search", {
+      params: { q: query, type: "track", limit: 1 },
+      headers: { "Authorization": "Bearer " + settings.apiKey }
+    });
+
+    if (response.ok) {
+      const data = JSON.parse(response.body);
+      if (data.tracks && data.tracks.length > 0) {
+        trackId = data.tracks[0].id;
+      }
+    }
+  }
+
+  return {
+    available: trackId !== null,
+    track_id: trackId,
+    quality: settings.quality || "LOSSLESS"
+  };
+}
+
+/**
+ * Get download URL for track
+ * @param {string} trackId - Track ID from checkAvailability
+ * @param {string} quality - Requested quality
+ * @returns {Object} Download info
+ */
+function getDownloadUrl(trackId, quality) {
+  const response = http.get("https://api.mymusic.com/tracks/" + trackId + "/stream", {
+    params: { quality: quality },
+    headers: { "Authorization": "Bearer " + settings.apiKey }
+  });
+
+  if (!response.ok) {
+    return { success: false, error: "Failed to get stream URL" };
+  }
+
+  const data = JSON.parse(response.body);
+
+  return {
+    success: true,
+    url: data.url,
+    format: data.format,        // "flac", "mp3", "m4a"
+    quality: data.quality,
+    bit_depth: data.bitDepth,   // 16, 24
+    sample_rate: data.sampleRate // 44100, 96000, etc
+  };
+}
+
+
+/**
+ * Download track to file
+ * @param {Object} request - Download request
+ * @param {Function} progressCallback - Progress callback
+ * @returns {Object} Download result
+ */
+function download(request, progressCallback) {
+  log.info("Downloading:", request.track_name);
+
+  // 1. Check availability
+  const availability = checkAvailability(
+    request.isrc,
+    request.track_name,
+    request.artist_name
+  );
+
+  if (!availability.available) {
+    return {
+      success: false,
+      error: "Track not available",
+      error_type: "not_found"
+    };
+  }
+
+  // 2. Get download URL
+  const downloadInfo = getDownloadUrl(
+    availability.track_id,
+    request.quality || "LOSSLESS"
+  );
+
+  if (!downloadInfo.success) {
+    return {
+      success: false,
+      error: downloadInfo.error,
+      error_type: "stream_error"
+    };
+  }
+
+  // 3. Build output filename
+  const extension = downloadInfo.format === "flac" ? ".flac" : ".m4a";
+  const filename = gobackend.sanitizeFilename(
+    request.track_name + " - " + request.artist_name
+  ) + extension;
+  const outputPath = request.output_dir + "/" + filename;
+
+  // 4. Download file with progress
+  const result = file.download(downloadInfo.url, outputPath, {
+    headers: {
+      "Authorization": "Bearer " + settings.apiKey
+    },
+    onProgress: function(received, total) {
+      const percent = total > 0 ? received / total : 0;
+      progressCallback(percent);
+    }
+  });
+
+  if (!result.success) {
+    return {
+      success: false,
+      error: "Download failed: " + result.error,
+      error_type: "download_error"
+    };
+  }
+
+  // 5. Return success
+  return {
+    success: true,
+    file_path: outputPath,
+    format: downloadInfo.format,
+    actual_bit_depth: downloadInfo.bit_depth,
+    actual_sample_rate: downloadInfo.sample_rate
+  };
+}
+
+// Export functions (required at end of file)
+// SpotiFLAC will call these functions
+
+// ============================================
+// REGISTER EXTENSION (REQUIRED!)
+// ============================================
+// You MUST call registerExtension() at the end of your script
+// to register your extension with SpotiFLAC.
+// Pass an object containing all your provider functions.
+
+registerExtension({
+  // Lifecycle (required)
+  initialize: initialize,
+  cleanup: cleanup,
+  
+  // Metadata Provider functions (if type includes "metadata_provider")
+  searchTracks: searchTracks,
+  getTrack: getTrack,
+  getAlbum: getAlbum,
+  getArtist: getArtist,
+  
+  // Lazy enrichment hook (optional, called before download)
+  enrichTrack: enrichTrack,
+  
+  // Download Provider functions (if type includes "download_provider")
+  checkAvailability: checkAvailability,
+  getDownloadUrl: getDownloadUrl,
+  download: download
+});
+
+console.log("My Music Provider loaded!");
+
+

Important: registerExtension()

+

Every extension MUST call registerExtension() at the end of the script. This function registers your extension's functions with SpotiFLAC. Without this call, the extension will fail to load with the error: "extension did not call registerExtension()".

+
// Minimal example
+registerExtension({
+  initialize: function(config) { return true; },
+  cleanup: function() {},
+  searchTracks: function(query, limit) { return []; }
+});
+
+
+

API Reference

+

HTTP API

+

The HTTP API provides full control over network requests with automatic cookie management.

+
// GET request
+const response = http.get(url, headers);
+
+// POST request - body can be string or object (auto-stringified to JSON)
+const response = http.post(url, body, headers);
+
+// PUT request - same signature as POST
+const response = http.put(url, body, headers);
+
+// DELETE request - no body
+const response = http.delete(url, headers);
+
+// PATCH request - same signature as POST
+const response = http.patch(url, body, headers);
+
+// Generic request (supports any HTTP method)
+const response = http.request(url, {
+  method: "POST",           // HTTP method (default: "GET")
+  body: { key: "value" },   // Request body (string or object)
+  headers: {                // Request headers
+    "Authorization": "Bearer token",
+    "Content-Type": "application/json"
+  }
+});
+
+// Clear all cookies for this extension
+http.clearCookies();
+
+

Request Headers

+

Headers are optional. If you provide a custom User-Agent, it will be used instead of the default.

+
// Custom User-Agent is respected
+const response = http.get(url, {
+  "User-Agent": "MyExtension/1.0",
+  "Authorization": "Bearer token"
+});
+
+

Response Object

+
{
+  statusCode: 200,    // HTTP status code
+  status: 200,        // Alias for statusCode
+  ok: true,           // true if status code is 2xx
+  body: "...",        // Response body as string
+  headers: {          // Response headers
+    "Content-Type": "application/json",
+    "Set-Cookie": ["cookie1=value1", "cookie2=value2"]  // Arrays for multi-value headers
+  }
+}
+
+// Example: Parse JSON response
+const data = JSON.parse(response.body);
+// Or use utils helper
+const data = utils.parseJSON(response.body);
+
+

Form-Encoded POST (application/x-www-form-urlencoded)

+

For OAuth token exchanges and APIs that require form-encoded data:

+
// Method 1: Manual string building
+const formBody = "grant_type=authorization_code" +
+  "&client_id=" + encodeURIComponent(clientId) +
+  "&code=" + encodeURIComponent(authCode) +
+  "&redirect_uri=" + encodeURIComponent(redirectUri);
+
+const response = http.post("https://api.example.com/oauth/token", formBody, {
+  "Content-Type": "application/x-www-form-urlencoded"
+});
+
+// Method 2: Using URLSearchParams (browser-compatible)
+const params = new URLSearchParams();
+params.set("grant_type", "authorization_code");
+params.set("client_id", clientId);
+params.set("code", authCode);
+params.set("redirect_uri", redirectUri);
+
+const response = http.post("https://api.example.com/oauth/token", params.toString(), {
+  "Content-Type": "application/x-www-form-urlencoded"
+});
+
+// Method 3: Helper function
+function formEncode(obj) {
+  return Object.keys(obj)
+    .map(key => encodeURIComponent(key) + "=" + encodeURIComponent(obj[key]))
+    .join("&");
+}
+
+const response = http.post("https://api.example.com/oauth/token", formEncode({
+  grant_type: "authorization_code",
+  client_id: clientId,
+  code: authCode,
+  redirect_uri: redirectUri
+}), {
+  "Content-Type": "application/x-www-form-urlencoded"
+});
+
+

Important: When using form-encoded POST, you MUST set the Content-Type header to application/x-www-form-urlencoded. Otherwise, the default application/json will be used.

+ +

Each extension has its own persistent cookie jar. Cookies are automatically:

+
    +
  • Stored when received via Set-Cookie headers
  • +
  • Sent with subsequent requests to the same domain
  • +
+
// First request - server sets cookies
+http.get("https://api.example.com/login");
+
+// Second request - cookies are automatically included
+http.get("https://api.example.com/data");
+
+// Clear cookies if needed (e.g., for logout)
+http.clearCookies();
+
+

YouTube Music / Innertube API Example

+

For YouTube Music extensions, you need to declare all required domains in your manifest:

+
{
+  "permissions": {
+    "network": [
+      "music.youtube.com",
+      "www.youtube.com",
+      "youtube.com",
+      "youtubei.googleapis.com",
+      "*.googlevideo.com",
+      "*.youtube.com",
+      "*.ytimg.com"
+    ],
+    "storage": true
+  }
+}
+
+

Example Innertube API call:

+
async function searchYouTubeMusic(query) {
+  const visitorId = storage.get("visitorId") || "";
+  
+  const response = http.post("https://youtubei.googleapis.com/youtubei/v1/search", {
+    query: query,
+    context: {
+      client: {
+        clientName: "WEB_REMIX",
+        clientVersion: "1.20240101.01.00"
+      }
+    }
+  }, {
+    "Content-Type": "application/json",
+    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
+    "X-Goog-Visitor-Id": visitorId,
+    "X-Youtube-Client-Version": "1.20240101.01.00",
+    "X-Youtube-Client-Name": "67"
+  });
+  
+  // Save visitor ID from response headers for future requests
+  const newVisitorId = response.headers["X-Goog-Visitor-Id"];
+  if (newVisitorId) {
+    storage.set("visitorId", newVisitorId);
+  }
+  
+  if (!response.ok) {
+    log.error("YouTube Music search failed:", response.statusCode);
+    return [];
+  }
+  
+  return JSON.parse(response.body);
+}
+
+

Browser-like Polyfills

+

SpotiFLAC provides browser-compatible APIs to make porting web libraries easier. These polyfills work within the sandbox security model.

+

fetch() API

+

The global fetch() function provides a browser-compatible HTTP API:

+
// Basic GET request
+const response = fetch("https://api.example.com/data");
+const data = response.json();
+
+// POST request with options
+const response = fetch("https://api.example.com/search", {
+  method: "POST",
+  headers: {
+    "Content-Type": "application/json",
+    "Authorization": "Bearer token"
+  },
+  body: JSON.stringify({ query: "search term" })
+});
+
+if (response.ok) {
+  const data = response.json();
+  console.log(data);
+} else {
+  console.log("Error:", response.status, response.statusText);
+}
+
+

Response Object:

+
{
+  ok: true,              // true if status is 2xx
+  status: 200,           // HTTP status code
+  statusText: "OK",      // HTTP status text
+  url: "...",            // Request URL
+  headers: {},           // Response headers
+  
+  // Methods
+  text(),                // Returns body as string
+  json(),                // Parses body as JSON
+  arrayBuffer()          // Returns body as byte array
+}
+
+

Note: Unlike browser fetch, this is synchronous (not Promise-based) due to Goja VM limitations. However, the API signature is compatible for easier porting.

+

atob() / btoa()

+

Global Base64 encoding/decoding functions:

+
// Encode string to Base64
+const encoded = btoa("Hello, World!");  // "SGVsbG8sIFdvcmxkIQ=="
+
+// Decode Base64 to string
+const decoded = atob("SGVsbG8sIFdvcmxkIQ==");  // "Hello, World!"
+
+

TextEncoder / TextDecoder

+

For encoding/decoding text to/from byte arrays:

+
// Encode string to bytes (UTF-8)
+const encoder = new TextEncoder();
+const bytes = encoder.encode("Hello");  // [72, 101, 108, 108, 111]
+
+// Decode bytes to string
+const decoder = new TextDecoder("utf-8");
+const text = decoder.decode([72, 101, 108, 108, 111]);  // "Hello"
+
+

URL / URLSearchParams

+

For URL parsing and manipulation:

+
// Parse URL
+const url = new URL("https://example.com/path?foo=bar&baz=qux");
+console.log(url.hostname);    // "example.com"
+console.log(url.pathname);    // "/path"
+console.log(url.search);      // "?foo=bar&baz=qux"
+console.log(url.searchParams.get("foo"));  // "bar"
+
+// Build URL with query params
+const params = new URLSearchParams();
+params.set("query", "search term");
+params.set("limit", "10");
+const queryString = params.toString();  // "query=search+term&limit=10"
+
+// Relative URL resolution
+const base = new URL("https://example.com/api/");
+const full = new URL("users/123", base);
+console.log(full.href);  // "https://example.com/api/users/123"
+
+

Porting Browser Libraries

+

When porting browser libraries (like youtubei.js), you may need to:

+
    +
  1. Bundle the library using Webpack, Rollup, or Esbuild to create a single file
  2. +
  3. Replace unsupported APIs with SpotiFLAC equivalents: +
      +
    • fetch() → Already supported (synchronous version)
    • +
    • localStorage → Use storage.get/set
    • +
    • crypto.subtle → Use utils.md5/sha256 or credentials API
    • +
    +
  4. +
  5. Declare all domains in manifest permissions
  6. +
+

Example bundler config (Rollup):

+
// rollup.config.js
+export default {
+  input: 'src/index.js',
+  output: {
+    file: 'dist/index.js',
+    format: 'iife',  // Immediately Invoked Function Expression
+    name: 'MyExtension'
+  }
+};
+
+

Storage API

+
// Save data (persisted across app restarts)
+storage.set("key", "value");
+storage.set("config", { foo: "bar" });
+
+// Get data
+const value = storage.get("key");
+const config = storage.get("config");
+
+// Remove data
+storage.remove("key");
+
+

File API

+
// Download file
+const result = file.download(url, outputPath, {
+  headers: {},
+  onProgress: function (received, total) {
+    // Progress callback
+  },
+});
+
+// Check if file exists
+const exists = file.exists(path);
+
+// Read file content
+const content = file.read(path);
+
+// Write file
+file.write(path, content);
+
+// Delete file
+file.delete(path);
+
+

Note: File operations are limited to the extension's data directory.

+

Logging API

+
log.debug("Debug message", data);
+log.info("Info message", data);
+log.warn("Warning message", data);
+log.error("Error message", data);
+
+

Utility API

+
// JSON
+const obj = utils.parseJSON(jsonString);
+const str = utils.stringifyJSON(obj);
+
+// Encoding
+const encoded = utils.base64Encode(string);
+const decoded = utils.base64Decode(encoded);
+
+// Hashing
+const md5Hash = utils.md5(string);
+const sha256Hash = utils.sha256(string);
+
+// HMAC (for API signatures and TOTP)
+const signature = utils.hmacSHA256(message, secretKey);      // Returns hex string
+const signatureB64 = utils.hmacSHA256Base64(message, secretKey);  // Returns base64 string
+const hmacResult = utils.hmacSHA1(keyBytes, messageBytes);   // Returns array of bytes (for TOTP)
+
+

HMAC-SHA1 for TOTP

+

utils.hmacSHA1 is useful for implementing TOTP (Time-based One-Time Password) authentication:

+
function generateTOTP(secret, counter) {
+  // Decode base32 secret to bytes
+  const key = base32Decode(secret);
+  
+  // Convert counter to 8 bytes (big-endian)
+  const counterBytes = [];
+  let c = counter;
+  for (let i = 7; i >= 0; i--) {
+    counterBytes[i] = c & 0xff;
+    c = Math.floor(c / 256);
+  }
+  
+  // HMAC-SHA1 - key and message can be arrays of bytes
+  const hmac = utils.hmacSHA1(key, counterBytes);
+  
+  // Dynamic truncation
+  const offset = hmac[hmac.length - 1] & 0x0f;
+  const code = ((hmac[offset] & 0x7f) << 24) |
+               ((hmac[offset + 1] & 0xff) << 16) |
+               ((hmac[offset + 2] & 0xff) << 8) |
+               (hmac[offset + 3] & 0xff);
+  
+  return (code % 1000000).toString().padStart(6, "0");
+}
+
+// Usage
+const counter = Math.floor(Date.now() / 1000 / 30);
+const totpCode = generateTOTP(base32Secret, counter);
+
+

HMAC-SHA256 Example (API Signing)

+

Many APIs require request signing using HMAC-SHA256. Here's a complete example:

+
function signRequest(method, path, timestamp, body, secretKey) {
+  // Build string to sign (format varies by API)
+  const stringToSign = [method, path, timestamp, body].join("\n");
+  
+  // Generate HMAC-SHA256 signature
+  const signature = utils.hmacSHA256Base64(stringToSign, secretKey);
+  
+  return signature;
+}
+
+// Example: Signed API request
+function makeSignedRequest(endpoint, data) {
+  const timestamp = Date.now().toString();
+  const body = JSON.stringify(data);
+  const signature = signRequest("POST", endpoint, timestamp, body, settings.api_secret);
+  
+  return http.post("https://api.example.com" + endpoint, body, {
+    "Content-Type": "application/json",
+    "X-Timestamp": timestamp,
+    "X-Signature": signature,
+    "X-API-Key": settings.api_key
+  });
+}
+
+

Go Backend API

+
// Sanitize filename
+const safe = gobackend.sanitizeFilename(filename);
+
+// Get audio quality info from file
+const quality = gobackend.getAudioQuality(filePath);
+// returns object { bitDepth: 16, sampleRate: 44100, totalSamples: 12345 }
+// or { error: "..." }
+
+// Build filename from template
+const filename = gobackend.buildFilename(template, metadata);
+// metadata is object: { title, artist, album, track_number, ... }
+
+// Get device local time (accurate timezone detection)
+const localTime = gobackend.getLocalTime();
+// returns object:
+// {
+//   year: 2026,
+//   month: 1,           // 1-12
+//   day: 22,            // 1-31
+//   hour: 14,           // 0-23 (local time)
+//   minute: 30,         // 0-59
+//   second: 45,         // 0-59
+//   weekday: 4,         // 0=Sunday, 1=Monday, ..., 6=Saturday
+//   offsetMinutes: -420, // Timezone offset (JS convention: negative for east of UTC)
+//   timezone: "Asia/Jakarta", // Go timezone string
+//   timestamp: 1769140245     // Unix timestamp
+// }
+
+

Using getLocalTime() for Time-Based Greeting

+

The Goja JavaScript engine may return 0 for Date.getTimezoneOffset(), making it unreliable for timezone detection. Use gobackend.getLocalTime() instead:

+
function getTimeBasedGreeting() {
+  // Use gobackend.getLocalTime() for accurate device time
+  var localTime = gobackend.getLocalTime();
+  var hour = localTime.hour;
+  
+  if (hour >= 5 && hour < 12) {
+    return "Good morning";
+  } else if (hour >= 12 && hour < 17) {
+    return "Good afternoon";
+  } else if (hour >= 17 && hour < 21) {
+    return "Good evening";
+  } else {
+    return "Good night";
+  }
+}
+
+

Using getLocalTime() for Timezone in API Calls

+
function fetchHomeFeed() {
+  // Get timezone for API request
+  let timeZone = "UTC";
+  try {
+    const localTime = gobackend.getLocalTime();
+    if (localTime.timezone && localTime.timezone !== "Local") {
+      timeZone = localTime.timezone;
+    } else {
+      // Map offset to timezone string if needed
+      const offsetMinutes = localTime.offsetMinutes;
+      const tzMap = {
+        '-420': 'Asia/Jakarta',      // UTC+7
+        '-480': 'Asia/Singapore',    // UTC+8
+        '-540': 'Asia/Tokyo',        // UTC+9
+        '0': 'Europe/London',        // UTC+0
+        '300': 'America/New_York',   // UTC-5
+        '480': 'America/Los_Angeles' // UTC-8
+      };
+      timeZone = tzMap[String(offsetMinutes)] || "UTC";
+    }
+  } catch (e) {
+    // Fallback to UTC
+  }
+  
+  // Use timezone in API request
+  const response = http.get("https://api.example.com/home?timezone=" + encodeURIComponent(timeZone));
+  // ...
+}
+
+

Credentials API (Encrypted)

+
// Store sensitive data (encrypted on disk)
+credentials.store("key", "value");
+credentials.store("config", { apiKey: "...", secret: "..." });
+
+// Get sensitive data (decrypted)
+const value = credentials.get("key");
+const config = credentials.get("config");
+
+// Check if credential exists
+const exists = credentials.has("key");
+
+// Remove credential
+credentials.remove("key");
+
+

Auth API (OAuth Support)

+
// Request app to open OAuth URL
+auth.openAuthUrl(authUrl, callbackUrl);
+
+// Get auth code (set by app after OAuth callback)
+const code = auth.getAuthCode();
+
+// Set auth tokens
+auth.setAuthCode({
+  code: "...",
+  access_token: "...",
+  refresh_token: "...",
+  expires_in: 3600
+});
+
+// Check if authenticated
+const isAuth = auth.isAuthenticated();
+
+// Get current tokens
+const tokens = auth.getTokens();
+// { access_token, refresh_token, is_authenticated, expires_at, is_expired }
+
+// Clear auth state (logout)
+auth.clearAuth();
+
+ +

PKCE (Proof Key for Code Exchange) is the recommended OAuth flow for mobile/native apps. SpotiFLAC provides built-in PKCE support for secure OAuth authentication.

+

Quick Start (High-Level API)

+
// 1. Start OAuth with PKCE (generates verifier/challenge automatically)
+const result = auth.startOAuthWithPKCE({
+  authUrl: "https://accounts.spotify.com/authorize",
+  clientId: "your_client_id",
+  redirectUri: "spotiflac://callback",
+  scope: "user-read-private playlist-read-private",
+  extraParams: {
+    show_dialog: "true"
+  }
+});
+
+if (result.success) {
+  log.info("OAuth URL opened:", result.authUrl);
+  log.info("PKCE verifier stored for later use");
+}
+
+// 2. After user authorizes, get the auth code
+const code = auth.getAuthCode();
+
+// 3. Exchange code for tokens (uses stored PKCE verifier automatically)
+const tokens = auth.exchangeCodeWithPKCE({
+  tokenUrl: "https://accounts.spotify.com/api/token",
+  clientId: "your_client_id",
+  redirectUri: "spotiflac://callback",
+  code: code
+});
+
+if (tokens.success) {
+  log.info("Access token:", tokens.access_token);
+  log.info("Refresh token:", tokens.refresh_token);
+  // Tokens are automatically stored in auth state
+}
+
+

Low-Level API (Manual Control)

+
// Generate PKCE pair manually
+const pkce = auth.generatePKCE(64);  // Optional length (43-128, default: 64)
+// { verifier: "...", challenge: "...", method: "S256" }
+
+// Get stored PKCE (if previously generated)
+const storedPKCE = auth.getPKCE();
+// { verifier: "...", challenge: "...", method: "S256" } or {}
+
+// Build your own OAuth URL with PKCE
+const authUrl = `https://accounts.spotify.com/authorize?` +
+  `client_id=${CLIENT_ID}` +
+  `&response_type=code` +
+  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
+  `&code_challenge=${pkce.challenge}` +
+  `&code_challenge_method=S256` +
+  `&scope=${encodeURIComponent(SCOPE)}`;
+
+// Open the URL
+auth.openAuthUrl(authUrl, REDIRECT_URI);
+
+// After callback, exchange manually using http.post
+const response = http.post("https://accounts.spotify.com/api/token",
+  `grant_type=authorization_code` +
+  `&client_id=${CLIENT_ID}` +
+  `&code=${authCode}` +
+  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
+  `&code_verifier=${pkce.verifier}`,
+  { "Content-Type": "application/x-www-form-urlencoded" }
+);
+
+

PKCE API Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FunctionDescription
auth.generatePKCE(length?)Generate PKCE verifier/challenge pair (stored automatically)
auth.getPKCE()Get stored PKCE pair
auth.startOAuthWithPKCE(config)High-level: generate PKCE + open OAuth URL
auth.exchangeCodeWithPKCE(config)High-level: exchange code using stored verifier
+

startOAuthWithPKCE config:

+
{
+  authUrl: string,      // Required: OAuth authorization endpoint
+  clientId: string,     // Required: Your OAuth client ID
+  redirectUri: string,  // Required: Callback URL
+  scope: string,        // Optional: OAuth scopes
+  extraParams: object   // Optional: Additional URL parameters
+}
+
+

exchangeCodeWithPKCE config:

+
{
+  tokenUrl: string,     // Required: OAuth token endpoint
+  clientId: string,     // Required: Your OAuth client ID
+  code: string,         // Required: Authorization code from callback
+  redirectUri: string,  // Optional: Must match authorization request
+  extraParams: object   // Optional: Additional form parameters
+}
+
+

Complete OAuth Example

+
let settings = {};
+const CLIENT_ID = "your_spotify_client_id";
+const REDIRECT_URI = "spotiflac://spotify-callback";
+const SCOPES = "user-read-private user-library-read playlist-read-private";
+
+function initialize(config) {
+  settings = config || {};
+  
+  // Check if already authenticated
+  if (auth.isAuthenticated()) {
+    const tokens = auth.getTokens();
+    if (!tokens.is_expired) {
+      log.info("Already authenticated");
+      return true;
+    }
+    // Token expired, need to refresh or re-auth
+    log.info("Token expired, need to re-authenticate");
+  }
+  
+  return true;
+}
+
+function startLogin() {
+  const result = auth.startOAuthWithPKCE({
+    authUrl: "https://accounts.spotify.com/authorize",
+    clientId: CLIENT_ID,
+    redirectUri: REDIRECT_URI,
+    scope: SCOPES
+  });
+  
+  if (!result.success) {
+    log.error("Failed to start OAuth:", result.error);
+    return false;
+  }
+  
+  log.info("Please authorize in the browser...");
+  return true;
+}
+
+function handleCallback() {
+  const code = auth.getAuthCode();
+  if (!code) {
+    log.error("No auth code received");
+    return false;
+  }
+  
+  const tokens = auth.exchangeCodeWithPKCE({
+    tokenUrl: "https://accounts.spotify.com/api/token",
+    clientId: CLIENT_ID,
+    redirectUri: REDIRECT_URI,
+    code: code
+  });
+  
+  if (!tokens.success) {
+    log.error("Token exchange failed:", tokens.error);
+    return false;
+  }
+  
+  log.info("Authentication successful!");
+  // Tokens are now stored and accessible via auth.getTokens()
+  return true;
+}
+
+function makeAuthenticatedRequest(url) {
+  const tokens = auth.getTokens();
+  if (!tokens.access_token) {
+    throw new Error("Not authenticated");
+  }
+  
+  return http.get(url, {
+    "Authorization": "Bearer " + tokens.access_token
+  });
+}
+
+// Register extension
+registerExtension({
+  initialize: initialize,
+  startLogin: startLogin,
+  handleCallback: handleCallback,
+  // ... other functions
+});
+
+

Crypto Utilities

+
// Encrypt string with key
+const encrypted = utils.encrypt("data", "key");
+// { success: true, data: "base64-encrypted" }
+
+// Decrypt string
+const decrypted = utils.decrypt(encrypted.data, "key");
+// { success: true, data: "data" }
+
+// Generate random key
+const key = utils.generateKey(32);
+// { success: true, key: "base64", hex: "hex" }
+
+

FFmpeg API (Post-Processing)

+
// Execute raw FFmpeg command
+const result = ffmpeg.execute('-i "input.flac" -c:a libmp3lame -b:a 320k "output.mp3"');
+// { success: true, output: "..." } or { success: false, error: "..." }
+
+// Get audio file info
+const info = ffmpeg.getInfo("file.flac");
+// { success: true, bit_depth: 16, sample_rate: 44100, duration: 180.5 }
+
+// Convert with options (helper function)
+const result = ffmpeg.convert("input.flac", "output.mp3", {
+  codec: "libmp3lame",    // Audio codec
+  bitrate: "320k",        // Bitrate
+  sample_rate: 44100,     // Sample rate
+  channels: 2             // Number of channels
+});
+
+

Track Matching API

+
// Compare two strings with fuzzy matching (returns 0-1 similarity)
+const similarity = matching.compareStrings("Track Name", "track name (remastered)");
+// 0.85
+
+// Compare durations with tolerance (in milliseconds)
+const match = matching.compareDuration(180000, 182000, 3000);
+// true (within 3 second tolerance)
+
+// Normalize string for comparison (removes suffixes, special chars)
+const normalized = matching.normalizeString("Track Name (Remastered) [Explicit]");
+// "track name"
+
+
+

Extension Examples

+

Example 1: Simple Metadata Provider

+

Extension that provides search from a public API.

+

manifest.json:

+
{
+  "name": "free-music-api",
+  "displayName": "Free Music API",
+  "version": "1.0.0",
+  "description": "Search from Free Music API",
+  "author": "Developer",
+  "permissions": {
+    "network": ["api.freemusic.com"]
+  },
+  "type": ["metadata_provider"],
+  "settings": []
+}
+
+

main.js:

+
let settings = {};
+
+function initialize(config) {
+  settings = config || {};
+  log.info("Free Music API initialized");
+  return true;
+}
+
+function cleanup() {
+  log.info("Cleanup");
+}
+
+function searchTracks(query, limit) {
+  const url = "https://api.freemusic.com/search?q=" + encodeURIComponent(query) + "&limit=" + limit;
+  const response = http.get(url, {});
+
+  if (!response.ok) return [];
+
+  const data = JSON.parse(response.body);
+  return data.results.map((t) => ({
+    id: t.id,
+    name: t.title,
+    artists: t.artist,
+    album_name: t.album,
+    isrc: t.isrc,
+    duration_ms: t.duration_ms,
+    images: t.artwork,
+  }));
+}
+
+// REQUIRED: Register the extension
+registerExtension({
+  initialize: initialize,
+  cleanup: cleanup,
+  searchTracks: searchTracks
+});
+
+

Example 2: Download Provider with Auth

+

Extension that provides downloads with authentication.

+

manifest.json:

+
{
+  "name": "premium-music",
+  "displayName": "Premium Music",
+  "version": "1.0.0",
+  "description": "Download from Premium Music service",
+  "author": "Developer",
+  "permissions": {
+    "network": [
+      "api.premiummusic.com",
+      "cdn.premiummusic.com"
+    ],
+    "storage": true
+  },
+  "type": ["download_provider"],
+  "settings": [
+    {
+      "key": "email",
+      "label": "Email",
+      "type": "string",
+      "required": true
+    },
+    {
+      "key": "password",
+      "label": "Password",
+      "type": "string",
+      "required": true
+    }
+  ]
+}
+
+

main.js:

+
let settings = {};
+let accessToken = null;
+
+function initialize(config) {
+  settings = config || {};
+
+  if (!settings.email || !settings.password) {
+    throw new Error("Email and password required");
+  }
+
+  // Login and save token
+  const body = JSON.stringify({
+    email: settings.email,
+    password: settings.password
+  });
+
+  const response = http.post("https://api.premiummusic.com/auth/login", body, {
+    "Content-Type": "application/json"
+  });
+
+  if (!response.ok) {
+    throw new Error("Login failed");
+  }
+
+  const data = JSON.parse(response.body);
+  accessToken = data.access_token;
+
+  // Save token for next session
+  storage.set("access_token", accessToken);
+
+  log.info("Logged in successfully");
+  return true;
+}
+
+function cleanup() {
+  accessToken = null;
+}
+
+function checkAvailability(isrc, trackName, artistName) {
+  const url = "https://api.premiummusic.com/search?isrc=" + isrc;
+  const response = http.get(url, {
+    Authorization: "Bearer " + accessToken
+  });
+
+  if (!response.ok) {
+    return { available: false };
+  }
+
+  const data = JSON.parse(response.body);
+  if (data.tracks && data.tracks.length > 0) {
+    return {
+      available: true,
+      track_id: data.tracks[0].id,
+      quality: "LOSSLESS",
+    };
+  }
+
+  return { available: false };
+}
+
+function getDownloadUrl(trackId, quality) {
+  const url = "https://api.premiummusic.com/tracks/" + trackId + "/download?quality=" + quality;
+  const response = http.get(url, {
+    Authorization: "Bearer " + accessToken
+  });
+
+  if (!response.ok) {
+    return { success: false, error: "Failed to get URL" };
+  }
+
+  const data = JSON.parse(response.body);
+  return {
+    success: true,
+    url: data.url,
+    format: "flac",
+    bit_depth: 24,
+    sample_rate: 96000,
+  };
+}
+
+function download(trackId, quality, outputPath, progressCallback) {
+  // 1. Get download URL directly (availability checked by app)
+  const downloadInfo = getDownloadUrl(trackId, quality);
+
+  if (!downloadInfo.success) {
+    return {
+      success: false,
+      error: downloadInfo.error,
+      error_type: "stream_error",
+    };
+  }
+
+  // 2. Download to outputPath provided by app
+  const result = file.download(downloadInfo.url, outputPath, {
+    headers: { Authorization: "Bearer " + accessToken },
+    onProgress: function(received, total) {
+      const percent = total > 0 ? (received / total) * 100 : 0;
+      progressCallback(percent);
+    }
+  });
+
+  if (!result.success) {
+    return {
+      success: false,
+      error: result.error,
+      error_type: "download_error",
+    };
+  }
+
+  return {
+    success: true,
+    file_path: outputPath,
+    format: "flac",
+    actual_bit_depth: downloadInfo.bit_depth,
+    actual_sample_rate: downloadInfo.sample_rate,
+  };
+}
+
+// REQUIRED: Register the extension
+registerExtension({
+  initialize: initialize,
+  cleanup: cleanup,
+  checkAvailability: checkAvailability,
+  getDownloadUrl: getDownloadUrl,
+  download: download
+});
+
+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

+

Extensions support subdirectories in the package. You can organize your code like this:

+
my-extension/
+├── manifest.json      # Required
+├── index.js           # Required (main entry point)
+├── icon.png           # Optional
+├── libs/              # Optional subdirectories
+│   ├── api.js
+│   └── utils.js
+└── assets/
+    └── config.json
+
+

When packaged as .spotiflac-ext, the directory structure is preserved.

+

Module System Limitation

+

Important: SpotiFLAC does NOT support require() or ES6 import statements. The JavaScript runtime is a simple sandbox without Node.js module system.

+

Recommended approaches:

+
    +
  1. +

    Single File (Recommended for simple extensions)

    +

    Write all code in one index.js file.

    +
  2. +
  3. +

    Bundle with a Build Tool (Recommended for complex extensions)

    +

    Use a bundler like esbuild, Rollup, or Webpack to combine multiple files into one.

    +
    # Example with esbuild
    +npm install -g esbuild
    +esbuild src/index.js --bundle --outfile=dist/index.js --format=iife
    +
    +
  4. +
  5. +

    Manual Loading via File API

    +

    If you need to load additional files at runtime, use the file.read() API:

    +
    // Load a JSON config file
    +const configData = file.read('assets/config.json');
    +const config = JSON.parse(configData);
    +
    +// Note: You cannot "eval" or execute JS files this way for security reasons
    +
    +
  6. +
+

Example build setup with esbuild:

+
my-extension/
+├── src/
+│   ├── index.js       # Entry point
+│   ├── api.js         # API functions
+│   └── utils.js       # Utility functions
+├── dist/
+│   └── index.js       # Bundled output
+├── manifest.json
+├── icon.png
+└── package.json
+
+
// package.json
+{
+  "scripts": {
+    "build": "esbuild src/index.js --bundle --outfile=dist/index.js --format=iife",
+    "package": "npm run build && zip -j my-extension.spotiflac-ext manifest.json dist/index.js icon.png"
+  }
+}
+
+

Creating Extension File

+
    +
  1. Create a folder with manifest.json and index.js files
  2. +
  3. ZIP the folder
  4. +
  5. Rename .zip to .spotiflac-ext
  6. +
+

Using Command Line:

+
# Windows (PowerShell)
+Compress-Archive -Path manifest.json, index.js -DestinationPath my-extension.zip
+Rename-Item my-extension.zip my-extension.spotiflac-ext
+
+# Linux/Mac
+zip my-extension.zip manifest.json index.js
+mv my-extension.zip my-extension.spotiflac-ext
+
+

Installing Extension

+
    +
  1. Open SpotiFLAC
  2. +
  3. Go to Settings → Extensions
  4. +
  5. Tap the "+" or "Import" button
  6. +
  7. Select the .spotiflac-ext file
  8. +
  9. Extension will be loaded and appear in the list
  10. +
+

Upgrading Extension

+

SpotiFLAC supports upgrading extensions without losing data:

+
    +
  1. Create a new version of your extension with a higher version number in manifest.json
  2. +
  3. Package the extension as usual
  4. +
  5. Install the new .spotiflac-ext file
  6. +
  7. SpotiFLAC will automatically detect it's an upgrade and: +
      +
    • Preserve the extension's data directory (settings, cached data)
    • +
    • Replace the extension code with the new version
    • +
    • Reload the extension
    • +
    +
  8. +
+

Important Notes:

+
    +
  • Upgrades only: You can only upgrade to a higher version. Downgrading is not allowed.
  • +
  • Same version: Installing the same version will show an error "Extension is already installed"
  • +
  • Data preservation: User settings and stored data are preserved during upgrades
  • +
+

Version Format: +Use semantic versioning (x.y.z):

+
    +
  • 1.0.01.0.1 (patch upgrade)
  • +
  • 1.0.01.1.0 (minor upgrade)
  • +
  • 1.0.02.0.0 (major upgrade)
  • +
  • 1.1.01.0.0 (downgrade) - not allowed
  • +
+
+

Troubleshooting

+

Error: "extension did not call registerExtension()"

+

The extension script did not call registerExtension() function.

+

Solution: Add registerExtension({...}) at the end of your script with all your provider functions. See the Main Script section for examples.

+

Error: "Permission denied for domain X" / "network access denied"

+

Extension is trying to access a domain not in permissions.network.

+

Solution: Add the domain to the permissions.network array in manifest. For services with multiple domains (like YouTube), you need to add ALL domains:

+
"permissions": {
+  "network": [
+    "music.youtube.com",
+    "www.youtube.com", 
+    "youtube.com",
+    "youtubei.googleapis.com",
+    "*.googlevideo.com",
+    "*.youtube.com",
+    "*.ytimg.com"
+  ]
+}
+
+

Common domains for popular services:

+
    +
  • YouTube Music: youtubei.googleapis.com, music.youtube.com, *.youtube.com, *.googlevideo.com, *.ytimg.com
  • +
  • SoundCloud: api.soundcloud.com, api-v2.soundcloud.com, *.sndcdn.com
  • +
  • Bandcamp: bandcamp.com, *.bandcamp.com, *.bcbits.com
  • +
+

Error: "POST body is [object Object]"

+

The HTTP POST body is being converted to string incorrectly.

+

Solution: As of v3.0.0-alpha.2, http.post() now automatically stringifies objects to JSON. If you're on an older version, manually stringify:

+
// Old way (still works)
+http.post(url, JSON.stringify(body), headers);
+
+// New way (v3.0.0-alpha.2+)
+http.post(url, body, headers);  // Objects auto-stringified
+
+

Error: "Function X is not defined"

+

SpotiFLAC cannot find the required function.

+

Solution: Make sure initialize and cleanup functions exist. If type includes metadata_provider, ensure searchTracks exists. If type includes download_provider, ensure checkAvailability, getDownloadUrl, and download exist.

+

Error: "Invalid manifest"

+

The manifest.json format is invalid.

+

Solution:

+
    +
  • Ensure JSON is valid (use a JSON validator)
  • +
  • Ensure all required fields exist
  • +
  • Ensure name is lowercase without spaces
  • +
+

Extension doesn't appear after install

+

Solution:

+
    +
  • Ensure file is a valid ZIP
  • +
  • Ensure manifest.json exists at ZIP root
  • +
  • Check logs for error messages
  • +
+

HTTP request fails

+

Solution:

+
    +
  • Ensure domain is in permissions.network
  • +
  • Check URL and parameters
  • +
  • Check response status and body for error messages
  • +
  • Use log.debug() for debugging
  • +
  • Check response.ok property (true if status 2xx)
  • +
+

Download fails

+

Solution:

+
    +
  • Ensure storage permission is in manifest
  • +
  • Ensure URL is valid and accessible
  • +
  • Check if server requires auth headers
  • +
+

Error: "file access denied: extension does not have 'file' permission"

+

Extension is trying to use file operations without the file permission.

+

Solution: Add "file": true to your permissions in manifest.json:

+
"permissions": {
+  "network": ["api.example.com"],
+  "storage": true,
+  "file": true
+}
+
+

Error: "file access denied: absolute paths are not allowed"

+

Extension is trying to access a file using an absolute path (e.g., /sdcard/Music/file.flac or C:\Music\file.flac).

+

Solution: Use relative paths within the extension's sandbox directory. For download operations, the app will automatically provide the correct output path. Example:

+
// Wrong - absolute path
+file.write("/sdcard/Music/song.flac", data);
+
+// Correct - relative path (within extension sandbox)
+file.write("cache/temp.flac", data);
+
+// Correct - use outputPath provided by download function
+function download(trackId, quality, outputPath, progressCallback) {
+  // outputPath is already the correct absolute path managed by the app
+  return file.download(streamUrl, outputPath, { headers: headers });
+}
+
+

Error: "file access denied: path 'X' is outside sandbox"

+

Extension is trying to access a file outside its sandbox using path traversal (e.g., ../../../etc/passwd).

+

Solution: Only use paths within your extension's data directory. Path traversal attempts are blocked for security.

+

Error: "Cannot downgrade extension"

+

You're trying to install an older version of an already installed extension.

+

Solution: SpotiFLAC only allows upgrades (higher version numbers). If you need to downgrade:

+
    +
  1. Uninstall the current extension first
  2. +
  3. Then install the older version
  4. +
+

Error: "Extension is already installed"

+

You're trying to install the same version that's already installed.

+

Solution: Bump the version number in manifest.json if you've made changes.

+

Error: "timeout: extension took too long to respond"

+

Extension function exceeded the execution time limit.

+

Solution:

+
    +
  • Default timeout is 30 seconds for most operations
  • +
  • Download operations have 5 minute timeout
  • +
  • Post-processing has 2 minute timeout
  • +
  • Optimize your code to avoid infinite loops or long-running operations
  • +
  • For downloads, ensure you're streaming data rather than loading everything into memory
  • +
+

Thumbnails not showing correctly in search results

+

Custom search results may show square thumbnails instead of the expected aspect ratio.

+

Solution:

+
    +
  1. Add thumbnailRatio to your searchBehavior in manifest:
    "searchBehavior": {
    +  "enabled": true,
    +  "thumbnailRatio": "wide"  // For 16:9 YouTube-style thumbnails
    +}
    +
    +
  2. +
  3. Reinstall/upgrade the extension after changing the manifest
  4. +
  5. Make sure your customSearch function returns images field with valid URLs
  6. +
+
+

Technical Details & Behavior

+

This section clarifies implementation details that may not be obvious from the API reference.

+

Token Refresh Handling

+

SpotiFLAC does NOT automatically refresh tokens. Extensions must handle token refresh manually.

+

Recommended Pattern:

+
function ensureValidToken() {
+  const tokens = auth.getTokens();
+  
+  // Check if token exists and is not expired
+  if (tokens.access_token && !tokens.is_expired) {
+    return true;
+  }
+  
+  // Token expired or missing - try to refresh
+  const refreshToken = credentials.get("refresh_token");
+  if (!refreshToken) {
+    return false; // Need full re-authentication
+  }
+  
+  // Call your OAuth provider's refresh endpoint
+  const response = http.post("https://api.example.com/oauth/token", {
+    grant_type: "refresh_token",
+    refresh_token: refreshToken,
+    client_id: settings.client_id
+  }, { "Content-Type": "application/json" });
+  
+  if (!response.ok) {
+    auth.clearAuth();
+    return false;
+  }
+  
+  const newTokens = JSON.parse(response.body);
+  
+  // Update stored tokens
+  credentials.store("access_token", newTokens.access_token);
+  if (newTokens.refresh_token) {
+    credentials.store("refresh_token", newTokens.refresh_token);
+  }
+  
+  // Update auth state
+  auth.setAuthCode({
+    access_token: newTokens.access_token,
+    refresh_token: newTokens.refresh_token || refreshToken,
+    expires_in: newTokens.expires_in
+  });
+  
+  return true;
+}
+
+// Use before any authenticated API call
+function makeAuthenticatedRequest(url) {
+  if (!ensureValidToken()) {
+    return { error: "Authentication required" };
+  }
+  
+  const tokens = auth.getTokens();
+  return http.get(url, {
+    "Authorization": "Bearer " + tokens.access_token
+  });
+}
+
+

Key Points:

+
    +
  • auth.getTokens().is_expired returns true if current time > expires_at
  • +
  • You must implement refresh logic yourself
  • +
  • Store refresh tokens using credentials.store() for persistence
  • +
  • Call auth.setAuthCode() after refresh to update the auth state
  • +
+

Storage Limits

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Storage TypeLimitNotes
storage APIUnlimitedStored as JSON in extension's data directory
credentials APIUnlimitedEncrypted with AES-GCM, stored in .credentials.enc
File APIUnlimitedLimited to extension's sandbox directory
+

Storage Location:

+
    +
  • Android: /data/data/com.zarz.spotiflac/files/extensions/{extension-id}/
  • +
  • Each extension has isolated storage (cannot access other extensions' data)
  • +
+

Best Practices:

+
    +
  • Don't store large binary data in storage - use File API instead
  • +
  • Clean up unused data in cleanup() function
  • +
  • Use credentials for sensitive data (API keys, tokens, passwords)
  • +
+

File API Path Resolution

+

All File API paths are relative to the extension's data directory unless an absolute path is provided.

+
// Relative paths (recommended)
+file.write("cache/data.json", data);     // → {ext_dir}/cache/data.json
+file.read("config.txt");                  // → {ext_dir}/config.txt
+file.exists("downloads/track.flac");      // → {ext_dir}/downloads/track.flac
+
+// Absolute paths (allowed for download queue integration)
+file.write("/storage/emulated/0/Music/track.flac", data);  // Allowed
+file.read("/sdcard/Download/file.txt");                     // Allowed
+
+

Security:

+
    +
  • Relative paths are sandboxed to extension's data directory
  • +
  • Attempting to escape sandbox (e.g., ../other-extension/) will fail
  • +
  • Absolute paths are allowed for download queue integration (app controls these paths)
  • +
+

Extension Data Directory Structure:

+
{extension-id}/
+├── storage.json          # storage API data
+├── .credentials.enc      # encrypted credentials
+├── cache/                # your cache files
+└── downloads/            # your download files
+
+

HTTP Redirect Handling

+

HTTP redirects are handled automatically by the HTTP client (follows redirects by default).

+
// Redirects are followed automatically
+const response = http.get("https://example.com/redirect");
+// response.url will be the final URL after redirects
+// response.statusCode will be the final response status
+
+// The HTTP client follows up to 10 redirects by default
+// If more redirects occur, the request will fail
+
+

Behavior:

+
    +
  • 301, 302, 303, 307, 308 redirects are followed automatically
  • +
  • Cookies are preserved across redirects (same domain)
  • +
  • Maximum 10 redirects (Go's http.Client default)
  • +
  • Final response is returned (not intermediate redirects)
  • +
+

If you need to prevent redirects (rare), you can check the response and handle manually:

+
// Most cases: just use the response directly
+const response = http.get(url);
+if (response.ok) {
+  // Final response after any redirects
+}
+
+

Standard Error Types

+

Use these standard error_type values in download results for consistent error handling:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
error_typeDescriptionUser Action
not_foundTrack not available on this serviceTry another service
auth_errorAuthentication failed or expiredRe-authenticate
rate_limitToo many requests, rate limitedWait and retry
geo_blockedContent not available in user's regionUse VPN or try another service
stream_errorFailed to get stream URLRetry or try another quality
download_errorFile download failedCheck network and retry
format_errorUnsupported or invalid formatTry another quality
quota_exceededUser's download quota exceededWait for quota reset
premium_requiredPremium subscription requiredUpgrade account
server_errorService temporarily unavailableRetry later
+

Example Usage:

+
function download(request, progressCallback) {
+  // Check availability
+  const availability = checkAvailability(request.isrc, request.track_name, request.artist_name);
+  
+  if (!availability.available) {
+    return {
+      success: false,
+      error: "Track not found on this service",
+      error_type: "not_found"
+    };
+  }
+  
+  // Check authentication
+  if (!auth.isAuthenticated()) {
+    return {
+      success: false,
+      error: "Please authenticate first",
+      error_type: "auth_error"
+    };
+  }
+  
+  // Get stream URL
+  const streamInfo = getDownloadUrl(availability.track_id, request.quality);
+  
+  if (!streamInfo.success) {
+    // Determine error type from response
+    if (streamInfo.status === 429) {
+      return {
+        success: false,
+        error: "Rate limited, please wait",
+        error_type: "rate_limit"
+      };
+    }
+    if (streamInfo.status === 451 || streamInfo.status === 403) {
+      return {
+        success: false,
+        error: "Content not available in your region",
+        error_type: "geo_blocked"
+      };
+    }
+    return {
+      success: false,
+      error: streamInfo.error || "Failed to get stream",
+      error_type: "stream_error"
+    };
+  }
+  
+  // Download file
+  const result = file.download(streamInfo.url, request.output_path, {
+    headers: { "Authorization": "Bearer " + auth.getTokens().access_token },
+    onProgress: progressCallback
+  });
+  
+  if (!result.success) {
+    return {
+      success: false,
+      error: "Download failed: " + result.error,
+      error_type: "download_error"
+    };
+  }
+  
+  return {
+    success: true,
+    file_path: request.output_path,
+    format: streamInfo.format
+  };
+}
+
+

HTTP Timeout

+

The HTTP client has a 30 second timeout for all requests.

+
// Requests that take longer than 30 seconds will fail
+const response = http.get("https://slow-api.example.com/data");
+if (response.error) {
+  // Could be timeout: "context deadline exceeded" or similar
+  log.error("Request failed:", response.error);
+}
+
+

For large file downloads, use file.download() which has a longer timeout and supports progress callbacks.

+
+

Tips & Best Practices

+
    +
  1. Always handle errors - Wrap HTTP calls in try-catch
  2. +
  3. Use logging - log.debug() is very helpful for debugging
  4. +
  5. Validate settings - Check required settings in initialize()
  6. +
  7. Cache tokens - Use storage to save auth tokens
  8. +
  9. Respect rate limits - Don't spam APIs
  10. +
  11. Test thoroughly - Test with various inputs before distribution
  12. +
  13. List all domains - For complex APIs (YouTube, etc.), list ALL required domains in permissions
  14. +
+
+

Authentication API

+

SpotiFLAC provides a built-in authentication system for extensions that need OAuth or other auth flows (e.g., Apple Music, Spotify Premium, etc.).

+

Auth API Reference

+
// Request the app to open an OAuth URL
+// The app will open this URL in a browser and wait for callback
+auth.openAuthUrl(authUrl, callbackUrl);
+
+// Get the auth code (set by app after OAuth callback)
+const code = auth.getAuthCode();
+
+// Set auth tokens after exchanging code for tokens
+auth.setAuthCode({
+  code: "auth_code",
+  access_token: "access_token",
+  refresh_token: "refresh_token",
+  expires_in: 3600  // seconds
+});
+
+// Check if extension is authenticated
+const isAuth = auth.isAuthenticated();
+
+// Get current tokens
+const tokens = auth.getTokens();
+// Returns: { access_token, refresh_token, is_authenticated, expires_at, is_expired }
+
+// Clear all auth state (logout)
+auth.clearAuth();
+
+

Credentials API (Encrypted Storage)

+

For storing sensitive data like API keys, passwords, or tokens, use the encrypted credentials API:

+
// Store a credential (encrypted on disk)
+credentials.store("api_key", "my-secret-key");
+credentials.store("user_data", { email: "user@example.com", token: "..." });
+
+// Get a credential
+const apiKey = credentials.get("api_key");
+const userData = credentials.get("user_data");
+
+// Check if credential exists
+const hasKey = credentials.has("api_key");
+
+// Remove a credential
+credentials.remove("api_key");
+
+

Crypto Utilities

+

For custom encryption needs:

+
// Encrypt data with a key
+const result = utils.encrypt("sensitive data", "encryption-key");
+// Returns: { success: true, data: "base64-encrypted-string" }
+
+// Decrypt data
+const decrypted = utils.decrypt(result.data, "encryption-key");
+// Returns: { success: true, data: "sensitive data" }
+
+// Generate a random encryption key
+const key = utils.generateKey(32);  // 32 bytes = 256 bits
+// Returns: { success: true, key: "base64-key", hex: "hex-key" }
+
+

OAuth Flow Example

+

Here's a complete example of implementing OAuth authentication:

+
let settings = {};
+let accessToken = null;
+
+function initialize(config) {
+  settings = config || {};
+  
+  // Check if we have stored tokens
+  const storedToken = credentials.get("access_token");
+  if (storedToken) {
+    accessToken = storedToken;
+    // Verify token is still valid
+    if (auth.isAuthenticated()) {
+      log.info("Using stored authentication");
+      return true;
+    }
+  }
+  
+  // Need to authenticate
+  log.info("Authentication required");
+  return true;
+}
+
+// Call this to start OAuth flow
+function startAuth() {
+  const clientId = settings.client_id;
+  const redirectUri = "spotiflac://oauth/callback";
+  
+  const authUrl = `https://api.example.com/oauth/authorize?` +
+    `client_id=${clientId}&` +
+    `redirect_uri=${encodeURIComponent(redirectUri)}&` +
+    `response_type=code&` +
+    `scope=read,download`;
+  
+  // Request app to open auth URL
+  auth.openAuthUrl(authUrl, redirectUri);
+  
+  return { success: true, message: "Please complete authentication in browser" };
+}
+
+// Call this after user completes OAuth (app will set the code)
+function completeAuth() {
+  const code = auth.getAuthCode();
+  if (!code) {
+    return { success: false, error: "No auth code received" };
+  }
+  
+  // Exchange code for tokens
+  const response = http.post("https://api.example.com/oauth/token", JSON.stringify({
+    grant_type: "authorization_code",
+    code: code,
+    client_id: settings.client_id,
+    client_secret: settings.client_secret
+  }), {
+    "Content-Type": "application/json"
+  });
+  
+  if (response.statusCode !== 200) {
+    return { success: false, error: "Token exchange failed" };
+  }
+  
+  const tokens = JSON.parse(response.body);
+  
+  // Store tokens securely
+  credentials.store("access_token", tokens.access_token);
+  credentials.store("refresh_token", tokens.refresh_token);
+  
+  // Update auth state
+  auth.setAuthCode({
+    access_token: tokens.access_token,
+    refresh_token: tokens.refresh_token,
+    expires_in: tokens.expires_in
+  });
+  
+  accessToken = tokens.access_token;
+  
+  return { success: true };
+}
+
+// Use in download function
+function download(trackId, quality, outputPath, progressCallback) {
+  if (!auth.isAuthenticated()) {
+    return { success: false, error: "Not authenticated", error_type: "auth_error" };
+  }
+  
+  const tokens = auth.getTokens();
+  if (tokens.is_expired) {
+    // Refresh token
+    const refreshed = refreshAccessToken();
+    if (!refreshed.success) {
+      return { success: false, error: "Token refresh failed", error_type: "auth_error" };
+    }
+  }
+  
+  // Use accessToken for API calls
+  const response = http.get(`https://api.example.com/tracks/${trackId}/stream`, {
+    "Authorization": "Bearer " + accessToken
+  });
+  
+  // ... rest of download logic
+}
+
+function refreshAccessToken() {
+  const refreshToken = credentials.get("refresh_token");
+  if (!refreshToken) {
+    return { success: false };
+  }
+  
+  const response = http.post("https://api.example.com/oauth/token", JSON.stringify({
+    grant_type: "refresh_token",
+    refresh_token: refreshToken,
+    client_id: settings.client_id
+  }), {
+    "Content-Type": "application/json"
+  });
+  
+  if (response.statusCode !== 200) {
+    return { success: false };
+  }
+  
+  const tokens = JSON.parse(response.body);
+  credentials.store("access_token", tokens.access_token);
+  accessToken = tokens.access_token;
+  
+  auth.setAuthCode({
+    access_token: tokens.access_token,
+    expires_in: tokens.expires_in
+  });
+  
+  return { success: true };
+}
+
+// Register extension
+registerExtension({
+  initialize: initialize,
+  cleanup: function() { accessToken = null; },
+  startAuth: startAuth,
+  completeAuth: completeAuth,
+  download: download
+});
+
+
+

Data Schema Reference

+

Track Object

+
{
+  id: "track123",           // Unique ID (required)
+  name: "Track Name",       // Track title (required)
+  artists: "Artist Name",   // Artist(s) (required)
+  album_name: "Album",      // Album name (optional)
+  album_artist: "Artist",   // Album artist (optional)
+  isrc: "USRC12345678",     // ISRC code (optional but recommended for matching)
+  duration_ms: 240000,      // Duration in milliseconds (required)
+  track_number: 1,          // Track number (optional)
+  disc_number: 1,           // Disc number (optional)
+  release_date: "2024-01-01", // Release date (optional)
+  images: "https://..."     // Cover art/thumbnail URL (optional)
+}
+
+

Note on images field:

+
    +
  • For custom search results, this URL will be displayed as the track thumbnail
  • +
  • The aspect ratio is controlled by searchBehavior.thumbnailRatio in your manifest
  • +
  • Use high-quality URLs for best display (recommended: 300x300 for square, 480x270 for wide)
  • +
+

Album Object

+
{
+  id: "album123",
+  name: "Album Name",
+  artists: "Artist Name",
+  release_date: "2024-01-01",
+  total_tracks: 12,
+  images: "https://...",
+  album_type: "album",      // "album", "single", "compilation"
+  tracks: [/* array of Track objects */]
+}
+
+

Artist Object

+
{
+  id: "artist123",
+  name: "Artist Name",
+  images: "https://...",
+  albums: [/* array of Album objects */]
+}
+
+

Download Result Object

+
// Success
+{
+  success: true,
+  file_path: "/path/to/file.flac",
+  format: "flac",
+  actual_bit_depth: 24,
+  actual_sample_rate: 96000,
+  // Optional metadata (used when skipMetadataEnrichment is true)
+  title: "Track Name",
+  artist: "Artist Name",
+  album: "Album Name",
+  album_artist: "Album Artist",
+  track_number: 1,
+  disc_number: 1,
+  release_date: "2024-01-01",
+  cover_url: "https://...",
+  isrc: "USRC12345678"
+}
+
+// Error
+{
+  success: false,
+  error: "Error message",
+  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:

+
    +
  • YouTube downloads: The source already has metadata, no need to search Deezer/Spotify
  • +
  • Direct source downloads: When the extension provides complete metadata from its source
  • +
  • Performance: Skip unnecessary API calls to metadata providers
  • +
+

To use this feature:

+
    +
  1. Set "skipMetadataEnrichment": true in your manifest.json
  2. +
  3. Return metadata fields in your download() function result:
  4. +
+
function download(trackId, quality, outputPath, progressCallback) {
+  // ... download logic ...
+  
+  return {
+    success: true,
+    file_path: outputPath,
+    // Include metadata from your source
+    title: videoInfo.title,
+    artist: videoInfo.artist,
+    album: videoInfo.album || videoInfo.title,
+    cover_url: videoInfo.thumbnail
+  };
+}
+
+
+

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
  • +
+
+

Support

+

If you have questions or issues:

+
    +
  1. Open an issue on the GitHub repository
  2. +
  3. Include error logs and reproduction steps
  4. +
  5. Include SpotiFLAC and extension versions
  6. +
+

Happy coding!

+ +
+
+ + +
+ + +
+
+
+ + + +
+
+
Type to search across all documentation sections
+
+ +
+
+ + + + + + + diff --git a/site/downloads.html b/site/downloads.html index a95e31d2..57beb1bd 100644 --- a/site/downloads.html +++ b/site/downloads.html @@ -32,7 +32,11 @@ } * { margin: 0; padding: 0; box-sizing: border-box; } - html { scroll-behavior: smooth; } + html { scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: #333 transparent; } + html::-webkit-scrollbar { width: 8px; } + html::-webkit-scrollbar-track { background: transparent; } + html::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } + html::-webkit-scrollbar-thumb:hover { background: #555; } body { font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; @@ -51,13 +55,13 @@ -webkit-backdrop-filter: blur(20px); } .nav-inner { - max-width: var(--max-w); margin: auto; + max-width: 1100px; margin: auto; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; height: 64px; } .nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; color: var(--text); } .nav-brand img { width: 32px; height: 32px; border-radius: 50%; } - .nav-links { display: flex; gap: 24px; list-style: none; } + .nav-links { display: flex; align-items: center; gap: 24px; list-style: none; } .nav-links a { color: var(--text-dim); font-size: .9rem; transition: color .2s; } .nav-links a:hover { color: var(--text); text-decoration: none; } .nav-links a.active { color: var(--text); font-weight: 600; } @@ -65,6 +69,21 @@ .nav-links .nav-icon:hover { opacity: 1; } .nav-links .nav-icon svg { width: 24px; height: 24px; fill: currentColor; } .nav-links .nav-divider { width: 1px; height: 20px; background: rgba(255,255,255,.15); margin-left: -4px; } + .search-trigger { + display: flex; align-items: center; gap: 6px; + background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12); + border-radius: 8px; padding: 6px 12px; + color: var(--text-dim); font-size: .85rem; cursor: pointer; + font-family: inherit; transition: color .2s, border-color .2s, background .2s; + white-space: nowrap; text-decoration: none; + } + .search-trigger:hover { color: var(--text); border-color: rgba(255,255,255,.25); background: rgba(255,255,255,.1); text-decoration: none; } + .search-trigger svg { width: 14px; height: 14px; fill: currentColor; flex-shrink: 0; } + .search-trigger kbd { + background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); + border-radius: 4px; padding: 0px 4px; font-size: .65rem; + font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px; + } /* ── PAGE HEADER ── */ .page-header { @@ -179,7 +198,7 @@ background: var(--surface); padding: 40px 24px; text-align: center; } - .footer-inner { max-width: var(--max-w); margin: auto; } + .footer-inner { max-width: 1100px; margin: auto; } .footer-links { display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; margin-bottom: 16px; } .footer-links a { color: var(--text-dim); font-size: .9rem; } .footer-links a:hover { color: var(--text); } @@ -282,23 +301,98 @@ } .icon-svg { width: 20px; height: 20px; fill: currentColor; } + + /* ── SEARCH MODAL ── */ + .search-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.6); + z-index: 300; opacity: 0; pointer-events: none; + transition: opacity .2s cubic-bezier(.4,0,.2,1); + display: flex; align-items: flex-start; justify-content: center; + padding-top: min(20vh, 140px); + } + .search-overlay.open { opacity: 1; pointer-events: auto; } + .search-modal { + background: var(--surface); border: 1px solid rgba(255,255,255,.1); + border-radius: 16px; width: 580px; max-width: calc(100vw - 32px); + max-height: min(70vh, 520px); display: flex; flex-direction: column; + box-shadow: 0 16px 70px rgba(0,0,0,.6); + transform: translateY(-12px) scale(.97); opacity: 0; + transition: transform .25s cubic-bezier(.4,0,.2,1), opacity .2s; + } + .search-overlay.open .search-modal { transform: translateY(0) scale(1); opacity: 1; } + .search-header { + display: flex; align-items: center; gap: 10px; + padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,.08); + } + .search-header svg { width: 18px; height: 18px; fill: var(--text-dim); flex-shrink: 0; } + .search-input { + flex: 1; background: none; border: none; outline: none; + color: var(--text); font-size: .95rem; font-family: inherit; + } + .search-input::placeholder { color: var(--text-dim); } + .search-esc { + background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); + border-radius: 4px; padding: 2px 7px; font-size: .72rem; + color: var(--text-dim); font-family: inherit; cursor: pointer; + } + .search-esc:hover { background: rgba(255,255,255,.14); } + .search-body { overflow-y: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: #333 transparent; } + .search-body::-webkit-scrollbar { width: 6px; } + .search-body::-webkit-scrollbar-track { background: transparent; } + .search-body::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } + .search-group-label { + padding: 8px 10px 4px; font-size: .72rem; font-weight: 600; + color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; + } + .search-item { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: 10px; cursor: pointer; + color: var(--text); font-size: .88rem; transition: background .15s; + } + .search-item:hover, .search-item.active { background: rgba(255,255,255,.07); } + .search-item svg { width: 16px; height: 16px; fill: var(--text-dim); flex-shrink: 0; } + .search-item-text { flex: 1; min-width: 0; } + .search-item-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .search-item-section { font-size: .78rem; color: var(--text-dim); margin-top: 1px; } + .search-item mark { background: rgba(29,185,84,.25); color: var(--text); border-radius: 2px; padding: 0 1px; } + .search-item .search-enter { color: var(--text-dim); font-size: .72rem; opacity: 0; transition: opacity .15s; } + .search-item.active .search-enter { opacity: 1; } + .search-empty { padding: 32px 16px; text-align: center; color: var(--text-dim); font-size: .9rem; } + .search-footer { + padding: 10px 16px; border-top: 1px solid rgba(255,255,255,.06); + display: flex; align-items: center; gap: 16px; font-size: .72rem; color: #555; + } + .search-footer kbd { + background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.08); + border-radius: 3px; padding: 1px 4px; font-family: inherit; font-size: .68rem; + } + @media (max-width: 640px) { + .search-trigger kbd { display: none; } + .search-overlay { padding-top: 16px; } + .search-modal { max-height: 80vh; border-radius: 14px; } + }