From 16ce6089fb7925f79e7b11df8d34e1e58a393549 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 18 Apr 2026 22:12:14 +0700 Subject: [PATCH] feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y Remove Tidal from built-in provider registry (metadata, search, download, URL parsing) and delete tidal.go. Introduce extension runtime APIs for lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and ISRC index management (addToISRCIndex). Refactor extension download response construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata helpers with AlreadyExists support and ISRC indexing. Switch download mirrors to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new localization keys and accessibility labels across all supported locales. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 15 - go_backend/exports.go | 108 +- go_backend/extension_providers.go | 407 +-- go_backend/extension_providers_test.go | 19 +- go_backend/extension_runtime_utils.go | 75 + go_backend/parallel.go | 34 +- go_backend/tidal.go | 2547 ----------------- go_backend/tidal_test.go | 222 -- go_backend/title_match_utils.go | 257 ++ ios/Runner/AppDelegate.swift | 15 - lib/l10n/app_localizations.dart | 370 +++ lib/l10n/app_localizations_de.dart | 226 ++ lib/l10n/app_localizations_en.dart | 226 ++ lib/l10n/app_localizations_es.dart | 226 ++ lib/l10n/app_localizations_fr.dart | 226 ++ lib/l10n/app_localizations_hi.dart | 226 ++ lib/l10n/app_localizations_id.dart | 226 ++ lib/l10n/app_localizations_ja.dart | 226 ++ lib/l10n/app_localizations_ko.dart | 226 ++ lib/l10n/app_localizations_nl.dart | 226 ++ lib/l10n/app_localizations_pt.dart | 226 ++ lib/l10n/app_localizations_ru.dart | 226 ++ lib/l10n/app_localizations_tr.dart | 226 ++ lib/l10n/app_localizations_zh.dart | 226 ++ lib/l10n/arb/app_en.arb | 312 ++ lib/models/settings.dart | 2 +- lib/models/settings.g.dart | 2 +- lib/providers/download_queue_provider.dart | 83 +- lib/providers/extension_provider.dart | 174 +- lib/providers/settings_provider.dart | 14 +- lib/screens/artist_screen.dart | 4 +- lib/screens/downloaded_album_screen.dart | 2 +- lib/screens/home_tab.dart | 99 +- lib/screens/local_album_screen.dart | 2 +- lib/screens/queue_tab.dart | 31 +- lib/screens/repo_tab.dart | 2 +- lib/screens/search_screen.dart | 6 +- .../settings/appearance_settings_page.dart | 2 +- .../settings/download_settings_page.dart | 151 +- lib/screens/settings/log_screen.dart | 36 +- .../settings/options_settings_page.dart | 8 +- lib/screens/setup_screen.dart | 2 +- .../store/extension_details_screen.dart | 11 +- lib/screens/track_metadata_screen.dart | 16 +- lib/screens/tutorial_screen.dart | 8 +- .../local_track_redownload_service.dart | 2 +- lib/services/platform_bridge.dart | 25 - lib/utils/clickable_metadata.dart | 13 +- lib/widgets/download_service_picker.dart | 298 +- 49 files changed, 4928 insertions(+), 3384 deletions(-) delete mode 100644 go_backend/tidal.go delete mode 100644 go_backend/tidal_test.go 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 5fdb3ebb..1cd1fb08 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2897,14 +2897,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getTidalMetadata" -> { - val resourceType = call.argument("resource_type") ?: "" - val resourceId = call.argument("resource_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getTidalMetadata(resourceType, resourceId) - } - result.success(response) - } "getProviderMetadata" -> { val providerId = call.argument("provider_id") ?: "" val resourceType = call.argument("resource_type") ?: "" @@ -2921,13 +2913,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "convertTidalToSpotifyDeezer" -> { - val url = call.argument("url") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.convertTidalToSpotifyDeezer(url) - } - result.success(response) - } "searchDeezerByISRC" -> { val isrc = call.argument("isrc") ?: "" val itemId = call.argument("item_id") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index 068aadc3..32270a6c 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1165,10 +1165,18 @@ func DownloadWithFallback(requestJSON string) (string, error) { return errorResponse("Download cancelled") } - allServices := []string{"tidal", "qobuz"} + allServices := make([]string, 0, len(getBuiltInProviderSpecs())) + for _, spec := range getBuiltInProviderSpecs() { + if spec.SupportsDownload { + allServices = append(allServices, spec.ID) + } + } + if len(allServices) == 0 { + return errorResponse("No built-in download providers available") + } preferredService := req.Service if !isBuiltInDownloadProvider(preferredService) { - preferredService = "tidal" + preferredService = allServices[0] } GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) @@ -1947,21 +1955,6 @@ func ClearTrackIDCache() { ClearTrackCache() } -func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) { - downloader := NewTidalDownloader() - results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) { downloader := NewQobuzDownloader() results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) @@ -2249,7 +2242,7 @@ func getExtensionProviderMetadataResponse( "artist_info": map[string]interface{}{ "id": artist.ID, "name": artist.Name, - "images": tidalFirstNonEmpty(artist.HeaderImage, artist.ImageURL), + "images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL), "cover_url": artist.ImageURL, "header_image": artist.HeaderImage, "provider_id": artist.ProviderID, @@ -2284,34 +2277,13 @@ func getExtensionProviderMetadataResponse( } } -func GetTidalMetadata(resourceType, resourceID string) (string, error) { - downloader := NewTidalDownloader() - - var data interface{} - var err error - - switch resourceType { - case "track": - data, err = downloader.GetTrackMetadata(resourceID) - case "album": - data, err = downloader.GetAlbumMetadata(resourceID) - case "artist": - data, err = downloader.GetArtistMetadata(resourceID) - case "playlist": - data, err = downloader.GetPlaylistMetadata(resourceID) - default: - return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType) +func firstNonEmptyTrimmed(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } } - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(jsonBytes), nil + return "" } func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) { @@ -2380,25 +2352,6 @@ func ParseQobuzURLExport(url string) (string, error) { return string(jsonBytes), nil } -func ParseTidalURLExport(url string) (string, error) { - resourceType, resourceID, err := parseTidalURL(url) - if err != nil { - return "", err - } - - result := map[string]string{ - "type": resourceType, - "id": resourceID, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func ParseProviderURLJSON(url string) (string, error) { parsers := []struct { providerID string @@ -2406,7 +2359,6 @@ func ParseProviderURLJSON(url string) (string, error) { }{ {providerID: "deezer", parse: parseDeezerURL}, {providerID: "qobuz", parse: parseQobuzURL}, - {providerID: "tidal", parse: parseTidalURL}, } for _, parser := range parsers { @@ -2431,32 +2383,6 @@ func ParseProviderURLJSON(url string) (string, error) { return "", fmt.Errorf("unsupported provider URL") } -func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) { - client := NewSongLinkClient() - availability, err := client.CheckAvailabilityFromURL(tidalURL) - if err != nil { - return "", err - } - - result := map[string]string{ - "spotify_id": availability.SpotifyID, - "deezer_id": availability.DeezerID, - "deezer_url": availability.DeezerURL, - "spotify_url": "", - } - - if availability.SpotifyID != "" { - result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func GetDeezerExtendedMetadata(trackID string) (string, error) { if trackID == "" { return "", fmt.Errorf("empty track ID") diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index affe9a4c..b8bff343 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -109,19 +109,6 @@ type builtInProviderSpec struct { } var builtInProviderRegistry = []builtInProviderSpec{ - { - ID: "tidal", - DisplayName: "Tidal", - SupportsMetadata: true, - SupportsDownload: true, - SupportsSearch: true, - GetMetadata: GetTidalMetadata, - SearchAll: SearchTidalAll, - SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) { - return NewTidalDownloader().SearchTracks(query, limit) - }, - Download: downloadWithBuiltInTidal, - }, { ID: "qobuz", DisplayName: "Qobuz", @@ -185,26 +172,6 @@ func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (Downlo return spec.Download(req) } -func downloadWithBuiltInTidal(req DownloadRequest) (DownloadResult, error) { - result, err := downloadFromTidal(req) - if err != nil { - return DownloadResult{}, err - } - return DownloadResult{ - FilePath: result.FilePath, - BitDepth: result.BitDepth, - SampleRate: result.SampleRate, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - LyricsLRC: result.LyricsLRC, - }, nil -} - func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) { result, err := downloadFromQobuz(req) if err != nil { @@ -226,6 +193,139 @@ func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) { }, nil } +func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { + if result == nil { + return DownloadResult{}, false + } + + downloadResult := DownloadResult{ + FilePath: strings.TrimSpace(result.FilePath), + BitDepth: result.BitDepth, + SampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + TotalTracks: result.TotalTracks, + DiscNumber: result.DiscNumber, + TotalDiscs: result.TotalDiscs, + ISRC: result.ISRC, + CoverURL: result.CoverURL, + Genre: result.Genre, + Label: result.Label, + Copyright: result.Copyright, + Composer: result.Composer, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + } + + alreadyExists := result.AlreadyExists + if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") { + alreadyExists = true + downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:") + } + + enrichResultQualityFromFile(&downloadResult) + return downloadResult, alreadyExists +} + +func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) { + if resp == nil || result == nil { + return + } + + if strings.TrimSpace(resp.Title) == "" && result.Title != "" { + resp.Title = result.Title + } + if strings.TrimSpace(resp.Artist) == "" && result.Artist != "" { + resp.Artist = result.Artist + } + if strings.TrimSpace(resp.Album) == "" && result.Album != "" { + resp.Album = result.Album + } + if strings.TrimSpace(resp.AlbumArtist) == "" && result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if resp.TrackNumber == 0 && result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if resp.DiscNumber == 0 && result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if resp.TotalTracks == 0 && result.TotalTracks > 0 { + resp.TotalTracks = result.TotalTracks + } + if resp.TotalDiscs == 0 && result.TotalDiscs > 0 { + resp.TotalDiscs = result.TotalDiscs + } + if strings.TrimSpace(resp.ReleaseDate) == "" && result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if strings.TrimSpace(resp.CoverURL) == "" && result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if strings.TrimSpace(resp.ISRC) == "" && result.ISRC != "" { + resp.ISRC = result.ISRC + } + if strings.TrimSpace(resp.Genre) == "" && result.Genre != "" { + resp.Genre = result.Genre + } + if strings.TrimSpace(resp.Label) == "" && result.Label != "" { + resp.Label = result.Label + } + if strings.TrimSpace(resp.Copyright) == "" && result.Copyright != "" { + resp.Copyright = result.Copyright + } + if strings.TrimSpace(resp.Composer) == "" && result.Composer != "" { + resp.Composer = result.Composer + } + if result.LyricsLRC != "" { + resp.LyricsLRC = result.LyricsLRC + } + if result.DecryptionKey != "" { + resp.DecryptionKey = result.DecryptionKey + } + if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil { + resp.Decryption = normalized + } +} + +func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) { + if resp == nil { + return + } + + if req.AlbumName != "" && resp.Album == "" { + resp.Album = req.AlbumName + } + if req.AlbumArtist != "" && resp.AlbumArtist == "" { + resp.AlbumArtist = req.AlbumArtist + } + if req.ReleaseDate != "" && resp.ReleaseDate == "" { + resp.ReleaseDate = req.ReleaseDate + } + if req.ISRC != "" && resp.ISRC == "" { + resp.ISRC = req.ISRC + } + if req.TrackNumber > 0 && resp.TrackNumber == 0 { + resp.TrackNumber = req.TrackNumber + } + if req.TotalTracks > 0 && resp.TotalTracks == 0 { + resp.TotalTracks = req.TotalTracks + } + if req.DiscNumber > 0 && resp.DiscNumber == 0 { + resp.DiscNumber = req.DiscNumber + } + if req.TotalDiscs > 0 && resp.TotalDiscs == 0 { + resp.TotalDiscs = req.TotalDiscs + } + if req.CoverURL != "" && resp.CoverURL == "" { + resp.CoverURL = req.CoverURL + } +} + func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { return availability != nil && availability.SkipFallback } @@ -262,12 +362,13 @@ type DownloadDecryptionInfo struct { } type ExtDownloadResult struct { - Success bool `json:"success"` - FilePath string `json:"file_path,omitempty"` - BitDepth int `json:"bit_depth,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErrorType string `json:"error_type,omitempty"` + Success bool `json:"success"` + FilePath string `json:"file_path,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorType string `json:"error_type,omitempty"` Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` @@ -1728,81 +1829,25 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } if err == nil && result.Success { - resp := &DownloadResponse{ - Success: true, - Message: "Downloaded from " + req.Source, - FilePath: result.FilePath, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: req.Source, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), - } - - if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) { - if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { - GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) - } else { - GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) - } - } else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { - GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath) + normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) + message := "Downloaded from " + req.Source + if alreadyExists { + message = "File already exists" } + resp := buildDownloadSuccessResponse( + req, + normalizedResult, + req.Source, + message, + normalizedResult.FilePath, + alreadyExists, + ) + overlayExtensionDownloadMetadata(&resp, result) if ext.Manifest.SkipMetadataEnrichment { resp.SkipMetadataEnrichment = true - if result.Title != "" { - resp.Title = result.Title - } - if result.Artist != "" { - resp.Artist = result.Artist - } - if result.Album != "" { - resp.Album = result.Album - } - if result.AlbumArtist != "" { - resp.AlbumArtist = result.AlbumArtist - } - if result.TrackNumber > 0 { - resp.TrackNumber = result.TrackNumber - } - if result.DiscNumber > 0 { - resp.DiscNumber = result.DiscNumber - } - if result.TotalTracks > 0 { - resp.TotalTracks = result.TotalTracks - } - if result.TotalDiscs > 0 { - resp.TotalDiscs = result.TotalDiscs - } - if result.ReleaseDate != "" { - resp.ReleaseDate = result.ReleaseDate - } - if result.CoverURL != "" { - resp.CoverURL = result.CoverURL - } - if result.ISRC != "" { - resp.ISRC = result.ISRC - } - if result.Genre != "" { - resp.Genre = result.Genre - } - if result.Label != "" { - resp.Label = result.Label - } - if result.Copyright != "" { - resp.Copyright = result.Copyright - } - if result.Composer != "" { - resp.Composer = result.Composer - } - if result.LyricsLRC != "" { - resp.LyricsLRC = result.LyricsLRC - } } + applyExtensionRequestFallbacks(&resp, req) if req.TrackName != "" && resp.Title == "" { resp.Title = req.TrackName @@ -1810,38 +1855,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if req.ArtistName != "" && resp.Artist == "" { resp.Artist = req.ArtistName } - if req.AlbumName != "" && resp.Album == "" { - resp.Album = req.AlbumName - } - if req.AlbumArtist != "" && resp.AlbumArtist == "" { - resp.AlbumArtist = req.AlbumArtist - } - if req.ReleaseDate != "" && resp.ReleaseDate == "" { - resp.ReleaseDate = req.ReleaseDate - } - if req.ISRC != "" && resp.ISRC == "" { - resp.ISRC = req.ISRC - } - if req.TrackNumber > 0 && resp.TrackNumber == 0 { - resp.TrackNumber = req.TrackNumber - } - if req.DiscNumber > 0 && resp.DiscNumber == 0 { - resp.DiscNumber = req.DiscNumber - } - if req.TotalTracks > 0 && resp.TotalTracks == 0 { - resp.TotalTracks = req.TotalTracks - } - if req.TotalDiscs > 0 && resp.TotalDiscs == 0 { - resp.TotalDiscs = req.TotalDiscs - } - if req.CoverURL != "" && resp.CoverURL == "" { - resp.CoverURL = req.CoverURL - } if req.Composer != "" && resp.Composer == "" { resp.Composer = req.Composer } - return resp, nil + if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) { + if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) + } + } else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath) + } + + if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { + indexISRC := strings.TrimSpace(resp.ISRC) + if indexISRC == "" { + indexISRC = strings.TrimSpace(req.ISRC) + } + if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { + AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) + } + } + + return &resp, nil } if err != nil { @@ -2003,84 +2041,47 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } if err == nil && result.Success { - resp := &DownloadResponse{ - Success: true, - Message: "Downloaded from " + providerID, - FilePath: result.FilePath, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: providerID, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) + message := "Downloaded from " + providerID + if alreadyExists { + message = "File already exists" } - if req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(result.FilePath) { - if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { + resp := buildDownloadSuccessResponse( + req, + normalizedResult, + providerID, + message, + normalizedResult.FilePath, + alreadyExists, + ) + overlayExtensionDownloadMetadata(&resp, result) + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + } + applyExtensionRequestFallbacks(&resp, req) + + if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) { + if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) } - } else if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { - GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", result.FilePath) + } else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") { + GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath) } - if ext.Manifest.SkipMetadataEnrichment { - resp.SkipMetadataEnrichment = true - if result.Title != "" { - resp.Title = result.Title + if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { + indexISRC := strings.TrimSpace(resp.ISRC) + if indexISRC == "" { + indexISRC = strings.TrimSpace(req.ISRC) } - if result.Artist != "" { - resp.Artist = result.Artist - } - if result.Album != "" { - resp.Album = result.Album - } - if result.AlbumArtist != "" { - resp.AlbumArtist = result.AlbumArtist - } - if result.TrackNumber > 0 { - resp.TrackNumber = result.TrackNumber - } - if result.DiscNumber > 0 { - resp.DiscNumber = result.DiscNumber - } - if result.ReleaseDate != "" { - resp.ReleaseDate = result.ReleaseDate - } - if result.CoverURL != "" { - resp.CoverURL = result.CoverURL - } - if result.ISRC != "" { - resp.ISRC = result.ISRC + if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { + AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) } } - if req.AlbumName != "" && resp.Album == "" { - resp.Album = req.AlbumName - } - if req.AlbumArtist != "" && resp.AlbumArtist == "" { - resp.AlbumArtist = req.AlbumArtist - } - if req.ReleaseDate != "" && resp.ReleaseDate == "" { - resp.ReleaseDate = req.ReleaseDate - } - if req.ISRC != "" && resp.ISRC == "" { - resp.ISRC = req.ISRC - } - if req.TrackNumber > 0 && resp.TrackNumber == 0 { - resp.TrackNumber = req.TrackNumber - } - if req.DiscNumber > 0 && resp.DiscNumber == 0 { - resp.DiscNumber = req.DiscNumber - } - if req.CoverURL != "" && resp.CoverURL == "" { - resp.CoverURL = req.CoverURL - } - - return resp, nil + return &resp, nil } if err != nil { diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 5b3a4c00..23ea1621 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -11,9 +11,9 @@ func TestSetMetadataProviderPriorityPreservesExplicitProvidersOnly(t *testing.T) original := GetMetadataProviderPriority() defer SetMetadataProviderPriority(original) - SetMetadataProviderPriority([]string{"tidal"}) + SetMetadataProviderPriority([]string{"qobuz"}) got := GetMetadataProviderPriority() - want := []string{"tidal"} + want := []string{"qobuz"} if len(got) != len(want) { t.Fatalf("unexpected priority length: got %v want %v", got, want) } @@ -28,7 +28,7 @@ func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) original := GetExtensionFallbackProviderIDs() defer SetExtensionFallbackProviderIDs(original) - SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "}) + SetExtensionFallbackProviderIDs([]string{"ext-a", "qobuz", "ext-a", " ext-b "}) got := GetExtensionFallbackProviderIDs() want := []string{"ext-a", "ext-b"} @@ -255,7 +255,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { searchBuiltInMetadataTracksFunc = originalSearch }() - SetMetadataProviderPriority([]string{"qobuz", "tidal"}) + SetMetadataProviderPriority([]string{"qobuz"}) var calls []string searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { @@ -264,11 +264,8 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { case "qobuz": return []ExtTrackMetadata{ {ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"}, - }, nil - case "tidal": - return []ExtTrackMetadata{ - {ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"}, - {ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"}, + {ProviderID: "qobuz", SpotifyID: "qobuz:2", ISRC: "AAA111", Name: "Duplicate"}, + {ProviderID: "qobuz", SpotifyID: "qobuz:3", ISRC: "BBB222", Name: "Second"}, }, nil default: return nil, nil @@ -283,10 +280,10 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { if len(tracks) != 2 { t.Fatalf("unexpected track count: got %d want 2", len(tracks)) } - if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" { + if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "qobuz" { t.Fatalf("unexpected track provider order: %+v", tracks) } - if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" { + if len(calls) != 1 || calls[0] != "qobuz" { t.Fatalf("unexpected provider call order: %v", calls) } } diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index d7c42bf3..5689b1d2 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -390,6 +390,81 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { }) }) + obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 3 { + return vm.ToValue(map[string]interface{}{ + "error": "spotifyID, trackName, and artistName are required", + }) + } + + spotifyID := strings.TrimSpace(call.Arguments[0].String()) + trackName := strings.TrimSpace(call.Arguments[1].String()) + artistName := strings.TrimSpace(call.Arguments[2].String()) + filePath := "" + if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) { + filePath = strings.TrimSpace(call.Arguments[3].String()) + } + var durationMs int64 + if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) { + durationMs = call.Arguments[4].ToInteger() + } + + lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs) + if err != nil { + return vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + return vm.ToValue(map[string]interface{}{ + "lyrics": lyrics, + }) + }) + + obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 2 { + return vm.ToValue(map[string]interface{}{ + "error": "outputDir and isrc are required", + }) + } + + outputDir := strings.TrimSpace(call.Arguments[0].String()) + isrc := strings.TrimSpace(call.Arguments[1].String()) + if outputDir == "" || isrc == "" { + return vm.ToValue(map[string]interface{}{ + "error": "outputDir and isrc are required", + }) + } + + filePath, exists := checkISRCExistsInternal(outputDir, isrc) + return vm.ToValue(map[string]interface{}{ + "exists": exists, + "filePath": filePath, + }) + }) + + obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 3 { + return vm.ToValue(map[string]interface{}{ + "error": "outputDir, isrc, and filePath are required", + }) + } + + outputDir := strings.TrimSpace(call.Arguments[0].String()) + isrc := strings.TrimSpace(call.Arguments[1].String()) + filePath := strings.TrimSpace(call.Arguments[2].String()) + if outputDir == "" || isrc == "" || filePath == "" { + return vm.ToValue(map[string]interface{}{ + "error": "outputDir, isrc, and filePath are required", + }) + } + + AddToISRCIndex(outputDir, isrc, filePath) + return vm.ToValue(map[string]interface{}{ + "success": true, + }) + }) + obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return vm.ToValue("") diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 2526e139..65f2816a 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -8,9 +8,8 @@ import ( ) type TrackIDCacheEntry struct { - TidalTrackID int64 - QobuzTrackID int64 - ExpiresAt time.Time + QobuzTrackID int64 + ExpiresAt time.Time } type TrackIDCache struct { @@ -68,25 +67,6 @@ func (c *TrackIDCache) pruneExpiredLocked(now time.Time) { } } -func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { - c.mu.Lock() - defer c.mu.Unlock() - - entry, exists := c.cache[isrc] - if !exists { - entry = &TrackIDCacheEntry{} - c.cache[isrc] = entry - } - entry.TidalTrackID = trackID - now := time.Now() - entry.ExpiresAt = now.Add(c.ttl) - - if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) { - c.pruneExpiredLocked(now) - c.lastCleanup = now - } -} - func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { c.mu.Lock() defer c.mu.Unlock() @@ -211,8 +191,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { defer func() { <-semaphore }() switch r.Service { - case "tidal": - preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) case "qobuz": preWarmQobuzCache(r.ISRC, r.SpotifyID) } @@ -222,14 +200,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { wg.Wait() } -func preWarmTidalCache(isrc, _, _ string) { - downloader := NewTidalDownloader() - track, err := downloader.SearchTrackByISRC(isrc) - if err == nil && track != nil { - GetTrackIDCache().SetTidal(isrc, track.ID) - } -} - // preWarmQobuzCache tries to get Qobuz Track ID in the following order: // 1. From SongLink (fast, no Qobuz API call needed) // 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database) diff --git a/go_backend/tidal.go b/go_backend/tidal.go deleted file mode 100644 index 7d964d1d..00000000 --- a/go_backend/tidal.go +++ /dev/null @@ -1,2547 +0,0 @@ -package gobackend - -import ( - "bufio" - "context" - "encoding/base64" - "encoding/json" - "encoding/xml" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" -) - -type TidalDownloader struct { - client *http.Client - apiURL string -} - -var ( - globalTidalDownloader *TidalDownloader - tidalDownloaderOnce sync.Once - tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) { - return t.getTrackSearchPage(query, limit) - } - tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) { - return t.getPublicTrack(resourceID) - } -) - -const ( - spotifyTrackBaseURL = "https://open.spotify.com/track/" - songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url=" - tidalPublicAPIBaseURL = "https://tidal.com/v1" - tidalPublicToken = "txNoH4kkV41MfH25" - tidalResourceBaseURL = "https://resources.tidal.com" - tidalCountryCode = "US" - tidalLocale = "en_US" - tidalDeviceType = "BROWSER" -) - -type TidalTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - ISRC string `json:"isrc"` - Copyright string `json:"copyright"` - AudioQuality string `json:"audioQuality"` - TrackNumber int `json:"trackNumber"` - VolumeNumber int `json:"volumeNumber"` - Duration int `json:"duration"` - Album struct { - ID int64 `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - ReleaseDate string `json:"releaseDate"` - URL string `json:"url"` - } `json:"album"` - Artists []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Picture string `json:"picture"` - } `json:"artists"` - Artist struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Picture string `json:"picture"` - } `json:"artist"` - MediaMetadata struct { - Tags []string `json:"tags"` - } `json:"mediaMetadata"` - URL string `json:"url"` -} - -type TidalAPIResponseV2 struct { - Version string `json:"version"` - Data struct { - TrackID int64 `json:"trackId"` - AssetPresentation string `json:"assetPresentation"` - AudioMode string `json:"audioMode"` - AudioQuality string `json:"audioQuality"` - ManifestMimeType string `json:"manifestMimeType"` - ManifestHash string `json:"manifestHash"` - Manifest string `json:"manifest"` - BitDepth int `json:"bitDepth"` - SampleRate int `json:"sampleRate"` - } `json:"data"` -} - -type TidalBTSManifest struct { - MimeType string `json:"mimeType"` - Codecs string `json:"codecs"` - EncryptionType string `json:"encryptionType"` - URLs []string `json:"urls"` -} - -type MPD struct { - XMLName xml.Name `xml:"MPD"` - Period struct { - AdaptationSet struct { - Representation struct { - SegmentTemplate struct { - Initialization string `xml:"initialization,attr"` - Media string `xml:"media,attr"` - Timeline struct { - Segments []struct { - Duration int `xml:"d,attr"` - Repeat int `xml:"r,attr"` - } `xml:"S"` - } `xml:"SegmentTimeline"` - } `xml:"SegmentTemplate"` - } `xml:"Representation"` - } `xml:"AdaptationSet"` - } `xml:"Period"` -} - -type tidalPublicArtist struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Picture string `json:"picture"` -} - -type tidalPublicAlbum struct { - ID int64 `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Cover string `json:"cover"` - ReleaseDate string `json:"releaseDate"` - Copyright string `json:"copyright"` - URL string `json:"url"` - NumberOfTracks int `json:"numberOfTracks"` - Explicit bool `json:"explicit"` - Artists []tidalPublicArtist `json:"artists"` -} - -type tidalPublicAlbumPage struct { - Rows []struct { - Modules []struct { - Type string `json:"type"` - Album tidalPublicAlbum `json:"album"` - PagedList struct { - DataAPIPath string `json:"dataApiPath"` - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []struct { - Item TidalTrack `json:"item"` - Type string `json:"type"` - } `json:"items"` - } `json:"pagedList"` - } `json:"modules"` - } `json:"rows"` -} - -type tidalPublicArtistPage struct { - Rows []struct { - Modules []struct { - Type string `json:"type"` - Title string `json:"title"` - Artist struct { - ID int64 `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Picture string `json:"picture"` - } `json:"artist"` - PagedList struct { - DataAPIPath string `json:"dataApiPath"` - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []tidalPublicAlbum `json:"items"` - } `json:"pagedList"` - } `json:"modules"` - } `json:"rows"` -} - -type tidalPublicArtistAlbumsPage struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []tidalPublicAlbum `json:"items"` -} - -type tidalPublicPlaylist struct { - UUID string `json:"uuid"` - Title string `json:"title"` - Description string `json:"description"` - Type string `json:"type"` - URL string `json:"url"` - Image string `json:"image"` - SquareImage string `json:"squareImage"` - NumberOfTracks int `json:"numberOfTracks"` - Creator struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"creator"` -} - -type tidalPublicPlaylistItemsPage struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []struct { - Item TidalTrack `json:"item"` - Type string `json:"type"` - } `json:"items"` -} - -type tidalPublicTrackSearchResponse struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []TidalTrack `json:"items"` -} - -func NewTidalDownloader() *TidalDownloader { - tidalDownloaderOnce.Do(func() { - globalTidalDownloader = &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), - } - - apis := globalTidalDownloader.GetAvailableAPIs() - if len(apis) > 0 { - globalTidalDownloader.apiURL = apis[0] - } - }) - return globalTidalDownloader -} - -func tidalPrefixedID(id string) string { - trimmed := strings.TrimSpace(id) - if trimmed == "" { - return "" - } - return "tidal:" + trimmed -} - -func tidalPrefixedNumericID(id int64) string { - if id <= 0 { - return "" - } - return fmt.Sprintf("tidal:%d", id) -} - -func tidalImageURL(imageID, size string) string { - normalizedID := strings.TrimSpace(imageID) - if normalizedID == "" || strings.TrimSpace(size) == "" { - return "" - } - return fmt.Sprintf( - "%s/images/%s/%s.jpg", - tidalResourceBaseURL, - strings.ReplaceAll(normalizedID, "-", "/"), - size, - ) -} - -func tidalFirstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func tidalJoinArtistNames(artists []tidalPublicArtist) string { - if len(artists) == 0 { - return "" - } - - names := make([]string, 0, len(artists)) - for _, artist := range artists { - if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { - names = append(names, trimmed) - } - } - return strings.Join(names, ", ") -} - -func tidalTrackArtistsDisplay(track *TidalTrack) string { - if track == nil { - return "" - } - - if len(track.Artists) > 0 { - names := make([]string, 0, len(track.Artists)) - for _, artist := range track.Artists { - if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { - names = append(names, trimmed) - } - } - if len(names) > 0 { - return strings.Join(names, ", ") - } - } - - return strings.TrimSpace(track.Artist.Name) -} - -func tidalTrackAlbumArtistDisplay(track *TidalTrack) string { - if track == nil { - return "" - } - - if len(track.Artists) > 0 { - names := make([]string, 0, len(track.Artists)) - for _, artist := range track.Artists { - if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" { - continue - } - if trimmed := strings.TrimSpace(artist.Name); trimmed != "" { - names = append(names, trimmed) - } - } - if len(names) > 0 { - return strings.Join(names, ", ") - } - } - - return strings.TrimSpace(track.Artist.Name) -} - -func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string { - if album == nil { - return "" - } - return tidalJoinArtistNames(album.Artists) -} - -func tidalTrackExternalURL(track *TidalTrack) string { - if track == nil { - return "" - } - if trimmed := strings.TrimSpace(track.URL); trimmed != "" { - return strings.Replace(trimmed, "http://", "https://", 1) - } - if track.ID > 0 { - return fmt.Sprintf("https://tidal.com/browse/track/%d", track.ID) - } - return "" -} - -func tidalAlbumExternalURL(album *tidalPublicAlbum) string { - if album == nil { - return "" - } - if trimmed := strings.TrimSpace(album.URL); trimmed != "" { - return strings.Replace(trimmed, "http://", "https://", 1) - } - if album.ID > 0 { - return fmt.Sprintf("https://tidal.com/browse/album/%d", album.ID) - } - return "" -} - -func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata { - if track == nil { - return TrackMetadata{} - } - - artistID := tidalPrefixedNumericID(track.Artist.ID) - if artistID == "" && len(track.Artists) > 0 { - artistID = tidalPrefixedNumericID(track.Artists[0].ID) - } - - return TrackMetadata{ - SpotifyID: tidalPrefixedNumericID(track.ID), - Artists: tidalTrackArtistsDisplay(track), - Name: strings.TrimSpace(track.Title), - AlbumName: strings.TrimSpace(track.Album.Title), - AlbumArtist: tidalTrackAlbumArtistDisplay(track), - DurationMS: track.Duration * 1000, - Images: tidalImageURL(track.Album.Cover, "1280x1280"), - ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), - TrackNumber: track.TrackNumber, - DiscNumber: track.VolumeNumber, - ExternalURL: tidalTrackExternalURL(track), - ISRC: strings.TrimSpace(track.ISRC), - AlbumID: tidalPrefixedNumericID(track.Album.ID), - ArtistID: artistID, - } -} - -func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata { - if track == nil { - return AlbumTrackMetadata{} - } - - return AlbumTrackMetadata{ - SpotifyID: tidalPrefixedNumericID(track.ID), - Artists: tidalTrackArtistsDisplay(track), - Name: strings.TrimSpace(track.Title), - AlbumName: strings.TrimSpace(track.Album.Title), - AlbumArtist: tidalTrackAlbumArtistDisplay(track), - DurationMS: track.Duration * 1000, - Images: tidalImageURL(track.Album.Cover, "1280x1280"), - ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate), - TrackNumber: track.TrackNumber, - DiscNumber: track.VolumeNumber, - ExternalURL: tidalTrackExternalURL(track), - ISRC: strings.TrimSpace(track.ISRC), - AlbumID: tidalPrefixedNumericID(track.Album.ID), - AlbumURL: strings.Replace(strings.TrimSpace(track.Album.URL), "http://", "https://", 1), - } -} - -func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata { - if album == nil { - return AlbumInfoMetadata{} - } - - artistID := "" - if len(album.Artists) > 0 { - artistID = tidalPrefixedNumericID(album.Artists[0].ID) - } - - return AlbumInfoMetadata{ - TotalTracks: album.NumberOfTracks, - Name: strings.TrimSpace(album.Title), - ReleaseDate: strings.TrimSpace(album.ReleaseDate), - Artists: tidalAlbumArtistsDisplay(album), - ArtistId: artistID, - Images: tidalImageURL(album.Cover, "1280x1280"), - Copyright: strings.TrimSpace(album.Copyright), - } -} - -func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata { - return tidalAlbumToArtistAlbumWithType(album, "") -} - -func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata { - if album == nil { - return ArtistAlbumMetadata{} - } - - albumType := strings.ToLower(strings.TrimSpace(album.Type)) - if albumType == "" { - albumType = strings.ToLower(strings.TrimSpace(fallbackType)) - } - if albumType == "" { - albumType = "album" - } - - return ArtistAlbumMetadata{ - ID: tidalPrefixedNumericID(album.ID), - Name: strings.TrimSpace(album.Title), - ReleaseDate: strings.TrimSpace(album.ReleaseDate), - TotalTracks: album.NumberOfTracks, - Images: tidalImageURL(album.Cover, "1280x1280"), - AlbumType: albumType, - Artists: tidalAlbumArtistsDisplay(album), - } -} - -func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string { - if playlist == nil { - return "" - } - if trimmed := strings.TrimSpace(playlist.Creator.Name); trimmed != "" { - return trimmed - } - if strings.EqualFold(strings.TrimSpace(playlist.Type), "ARTIST") { - return "Artist" - } - return "TIDAL" -} - -func tidalArtistAlbumTypeFromModuleTitle(title string) string { - normalized := strings.ToLower(strings.TrimSpace(title)) - switch normalized { - case "albums", "compilations", "appears on": - return "album" - case "ep & singles", "eps & singles", "singles", "ep", "eps": - return "single" - default: - return "" - } -} - -func tidalBuildMetadataURL(path string, extraQuery url.Values) string { - trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/") - if trimmedPath == "" { - return tidalPublicAPIBaseURL - } - - baseURL, err := url.Parse(tidalPublicAPIBaseURL + "/" + trimmedPath) - if err != nil { - return tidalPublicAPIBaseURL + "/" + trimmedPath - } - - query := baseURL.Query() - query.Set("countryCode", tidalCountryCode) - query.Set("locale", tidalLocale) - query.Set("deviceType", tidalDeviceType) - for key, values := range extraQuery { - query.Del(key) - for _, value := range values { - query.Add(key, value) - } - } - baseURL.RawQuery = query.Encode() - return baseURL.String() -} - -func (t *TidalDownloader) getTidalMetadataJSON(requestURL string, target interface{}) error { - req, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/json") - req.Header.Set("x-tidal-token", tidalPublicToken) - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("tidal metadata request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) - } - - return json.NewDecoder(resp.Body).Decode(target) -} - -func (t *TidalDownloader) getPublicTrack(resourceID string) (*TidalTrack, error) { - trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) - if err != nil || trackID <= 0 { - return nil, fmt.Errorf("invalid tidal track ID: %s", resourceID) - } - - requestURL := tidalBuildMetadataURL(fmt.Sprintf("tracks/%d", trackID), nil) - var track TidalTrack - if err := t.getTidalMetadataJSON(requestURL, &track); err != nil { - return nil, err - } - return &track, nil -} - -func (t *TidalDownloader) getAlbumPage(resourceID string) (*tidalPublicAlbumPage, error) { - albumID := strings.TrimSpace(resourceID) - if albumID == "" { - return nil, fmt.Errorf("invalid tidal album ID") - } - - requestURL := tidalBuildMetadataURL("pages/album", url.Values{"albumId": {albumID}}) - var page tidalPublicAlbumPage - if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { - return nil, err - } - return &page, nil -} - -func (t *TidalDownloader) getArtistPage(resourceID string) (*tidalPublicArtistPage, error) { - artistID := strings.TrimSpace(resourceID) - if artistID == "" { - return nil, fmt.Errorf("invalid tidal artist ID") - } - - requestURL := tidalBuildMetadataURL("pages/artist", url.Values{"artistId": {artistID}}) - var page tidalPublicArtistPage - if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { - return nil, err - } - return &page, nil -} - -func (t *TidalDownloader) getArtistAlbumsPage(dataAPIPath string, offset, limit int) (*tidalPublicArtistAlbumsPage, error) { - extraQuery := url.Values{} - if offset >= 0 { - extraQuery.Set("offset", strconv.Itoa(offset)) - } - if limit > 0 { - extraQuery.Set("limit", strconv.Itoa(limit)) - } - - requestURL := tidalBuildMetadataURL(dataAPIPath, extraQuery) - var page tidalPublicArtistAlbumsPage - if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { - return nil, err - } - return &page, nil -} - -func (t *TidalDownloader) getPlaylist(resourceID string) (*tidalPublicPlaylist, error) { - playlistID := strings.TrimSpace(resourceID) - if playlistID == "" { - return nil, fmt.Errorf("invalid tidal playlist ID") - } - - requestURL := tidalBuildMetadataURL("playlists/"+url.PathEscape(playlistID), nil) - var playlist tidalPublicPlaylist - if err := t.getTidalMetadataJSON(requestURL, &playlist); err != nil { - return nil, err - } - return &playlist, nil -} - -func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit int) (*tidalPublicPlaylistItemsPage, error) { - playlistID := strings.TrimSpace(resourceID) - if playlistID == "" { - return nil, fmt.Errorf("invalid tidal playlist ID") - } - - requestURL := tidalBuildMetadataURL( - "playlists/"+url.PathEscape(playlistID)+"/items", - url.Values{ - "offset": {strconv.Itoa(offset)}, - "limit": {strconv.Itoa(limit)}, - }, - ) - var page tidalPublicPlaylistItemsPage - if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { - return nil, err - } - return &page, nil -} - -func (t *TidalDownloader) getTrackSearchPage(query string, limit int) (*tidalPublicTrackSearchResponse, error) { - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" { - return nil, fmt.Errorf("empty tidal search query") - } - if limit <= 0 { - limit = 20 - } - - requestURL := tidalBuildMetadataURL( - "search/tracks", - url.Values{ - "query": {cleanQuery}, - "limit": {strconv.Itoa(limit)}, - "offset": {"0"}, - }, - ) - var page tidalPublicTrackSearchResponse - if err := t.getTidalMetadataJSON(requestURL, &page); err != nil { - return nil, err - } - return &page, nil -} - -func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *struct { - Type string `json:"type"` - Album tidalPublicAlbum `json:"album"` - PagedList struct { - DataAPIPath string `json:"dataApiPath"` - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []struct { - Item TidalTrack `json:"item"` - Type string `json:"type"` - } `json:"items"` - } `json:"pagedList"` -} { - if page == nil { - return nil - } - for rowIndex := range page.Rows { - for moduleIndex := range page.Rows[rowIndex].Modules { - module := &page.Rows[rowIndex].Modules[moduleIndex] - if module.Type == moduleType { - return module - } - } - } - return nil -} - -func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct { - Type string `json:"type"` - Title string `json:"title"` - Artist struct { - ID int64 `json:"id"` - Name string `json:"name"` - URL string `json:"url"` - Picture string `json:"picture"` - } `json:"artist"` - PagedList struct { - DataAPIPath string `json:"dataApiPath"` - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []tidalPublicAlbum `json:"items"` - } `json:"pagedList"` -} { - if page == nil { - return nil - } - for rowIndex := range page.Rows { - for moduleIndex := range page.Rows[rowIndex].Modules { - module := &page.Rows[rowIndex].Modules[moduleIndex] - if module.Type == moduleType { - return module - } - } - } - return nil -} - -func (t *TidalDownloader) GetAvailableAPIs() []string { - return []string{ - "https://eu-central.monochrome.tf", - "https://us-west.monochrome.tf", - "https://api.monochrome.tf", - "https://monochrome-api.samidy.com", - "https://tidal-api.binimum.org", - "https://tidal.kinoplus.online", - "https://triton.squid.wtf", - "https://vogel.qqdl.site", - "https://maus.qqdl.site", - "https://hund.qqdl.site", - "https://katze.qqdl.site", - "https://wolf.qqdl.site", - "https://hifi-one.spotisaver.net", - "https://hifi-two.spotisaver.net", - } -} - -func (t *TidalDownloader) GetAccessToken() (string, error) { - return "", fmt.Errorf("tidal official metadata API disabled: no client credentials mode") -} - -func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - spotifyURL := fmt.Sprintf("%s%s", spotifyTrackBaseURL, spotifyTrackID) - apiURL := fmt.Sprintf("%s%s", songLinkLookupBaseURL, url.QueryEscape(spotifyURL)) - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - return "", fmt.Errorf("failed to get Tidal URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("SongLink API returned status %d", resp.StatusCode) - } - - var songLinkResp struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` - } - if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - tidalLink, ok := songLinkResp.LinksByPlatform["tidal"] - if !ok || tidalLink.URL == "" { - return "", fmt.Errorf("tidal link not found in SongLink") - } - - return tidalLink.URL, nil -} - -func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { - parts := strings.Split(tidalURL, "/track/") - if len(parts) < 2 { - return 0, fmt.Errorf("invalid tidal URL format") - } - - trackIDStr := strings.Split(parts[1], "?")[0] - trackIDStr = strings.TrimSpace(trackIDStr) - - var trackID int64 - _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) - if err != nil { - return 0, fmt.Errorf("failed to parse track ID: %w", err) - } - - return trackID, nil -} - -func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { - return nil, fmt.Errorf("tidal track lookup API disabled: no client credentials mode") -} - -func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { - normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) - if normalizedISRC == "" { - return nil, fmt.Errorf("empty tidal ISRC") - } - - page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20) - if err != nil { - return nil, err - } - - for i := range page.Items { - if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) { - return &page.Items[i], nil - } - } - - return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC) -} - -func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { - queryParts := make([]string, 0, 3) - if trimmed := strings.TrimSpace(trackName); trimmed != "" { - queryParts = append(queryParts, trimmed) - } - if trimmed := strings.TrimSpace(artistName); trimmed != "" { - queryParts = append(queryParts, trimmed) - } - if len(queryParts) == 0 { - return nil, fmt.Errorf("tidal metadata search requires track or artist name") - } - - queries := []string{strings.Join(queryParts, " ")} - if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" { - queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " ")) - } - - req := DownloadRequest{ - TrackName: strings.TrimSpace(trackName), - ArtistName: strings.TrimSpace(artistName), - AlbumName: strings.TrimSpace(albumName), - ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)), - DurationMS: expectedDuration * 1000, - } - - seenQueries := make(map[string]struct{}, len(queries)) - for _, query := range queries { - if _, seen := seenQueries[query]; seen { - continue - } - seenQueries[query] = struct{}{} - - page, err := tidalGetTrackSearchPageFunc(t, query, 20) - if err != nil { - return nil, err - } - - var candidates []*TidalTrack - for i := range page.Items { - track := &page.Items[i] - if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) { - continue - } - resolved := resolvedTrackInfo{ - Title: strings.TrimSpace(track.Title), - ArtistName: tidalTrackArtistsDisplay(track), - ISRC: strings.TrimSpace(track.ISRC), - Duration: track.Duration, - } - if trackMatchesRequest(req, resolved, "Tidal search") { - candidates = append(candidates, track) - } - } - - if len(candidates) == 0 { - continue - } - - if req.AlbumName != "" { - for _, candidate := range candidates { - if titlesMatch(req.AlbumName, candidate.Album.Title) { - return candidate, nil - } - } - } - - return candidates[0], nil - } - - if req.ISRC != "" { - return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC) - } - return nil, fmt.Errorf("no tidal metadata match found") -} - -func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { - return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0) -} - -func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { - page, err := t.getTrackSearchPage(query, limit) - if err != nil { - return nil, err - } - - results := make([]ExtTrackMetadata, 0, len(page.Items)) - for i := range page.Items { - results = append(results, normalizeBuiltInMetadataTrack(tidalTrackToTrackMetadata(&page.Items[i]), "tidal")) - } - return results, nil -} - -func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { - GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) - - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" { - return nil, fmt.Errorf("empty tidal search query") - } - - albumLimit := 5 - - if filter != "" { - switch filter { - case "track": - trackLimit = 50 - artistLimit = 0 - albumLimit = 0 - case "artist": - trackLimit = 0 - artistLimit = 20 - albumLimit = 0 - case "album": - trackLimit = 0 - artistLimit = 0 - albumLimit = 20 - } - } - - result := &SearchAllResult{ - Tracks: make([]TrackMetadata, 0, trackLimit), - Artists: make([]SearchArtistResult, 0, artistLimit), - Albums: make([]SearchAlbumResult, 0, albumLimit), - Playlists: make([]SearchPlaylistResult, 0), - } - - if trackLimit > 0 { - page, err := t.getTrackSearchPage(cleanQuery, trackLimit) - if err != nil { - GoLog("[Tidal] Track search failed: %v\n", err) - return nil, fmt.Errorf("tidal track search failed: %w", err) - } - GoLog("[Tidal] Got %d tracks from API\n", len(page.Items)) - for i := range page.Items { - result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i])) - } - } - - if artistLimit > 0 { - requestURL := tidalBuildMetadataURL("search/artists", url.Values{ - "query": {cleanQuery}, - "limit": {strconv.Itoa(artistLimit)}, - "offset": {"0"}, - }) - var artistResp struct { - Items []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Picture string `json:"picture"` - Popularity int `json:"popularity"` - URL string `json:"url"` - } `json:"items"` - } - if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil { - GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items)) - for _, artist := range artistResp.Items { - result.Artists = append(result.Artists, SearchArtistResult{ - ID: tidalPrefixedNumericID(artist.ID), - Name: strings.TrimSpace(artist.Name), - Images: tidalImageURL(artist.Picture, "750x750"), - Followers: 0, - Popularity: artist.Popularity, - }) - } - } else { - GoLog("[Tidal] Artist search failed: %v\n", err) - } - } - - if albumLimit > 0 { - requestURL := tidalBuildMetadataURL("search/albums", url.Values{ - "query": {cleanQuery}, - "limit": {strconv.Itoa(albumLimit)}, - "offset": {"0"}, - }) - var albumResp struct { - Items []tidalPublicAlbum `json:"items"` - } - if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil { - GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items)) - for i := range albumResp.Items { - album := &albumResp.Items[i] - albumType := strings.ToLower(strings.TrimSpace(album.Type)) - if albumType == "" { - albumType = "album" - } - result.Albums = append(result.Albums, SearchAlbumResult{ - ID: tidalPrefixedNumericID(album.ID), - Name: strings.TrimSpace(album.Title), - Artists: tidalAlbumArtistsDisplay(album), - Images: tidalImageURL(album.Cover, "1280x1280"), - ReleaseDate: strings.TrimSpace(album.ReleaseDate), - TotalTracks: album.NumberOfTracks, - AlbumType: albumType, - }) - } - } else { - GoLog("[Tidal] Album search failed: %v\n", err) - } - } - - GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) - return result, nil -} - -func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { - track, err := t.getPublicTrack(resourceID) - if err != nil { - return nil, err - } - return &TrackResponse{Track: tidalTrackToTrackMetadata(track)}, nil -} - -func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { - page, err := t.getAlbumPage(resourceID) - if err != nil { - return nil, err - } - - headerModule := findTidalAlbumPageModule(page, "ALBUM_HEADER") - itemsModule := findTidalAlbumPageModule(page, "ALBUM_ITEMS") - if headerModule == nil { - return nil, fmt.Errorf("tidal album page missing album header") - } - if itemsModule == nil { - return nil, fmt.Errorf("tidal album page missing track list") - } - - tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) - totalDiscs := 0 - for _, item := range itemsModule.PagedList.Items { - track := item.Item - track.Album.ID = headerModule.Album.ID - track.Album.Title = headerModule.Album.Title - track.Album.Cover = headerModule.Album.Cover - track.Album.ReleaseDate = headerModule.Album.ReleaseDate - track.Album.URL = headerModule.Album.URL - if track.VolumeNumber > totalDiscs { - totalDiscs = track.VolumeNumber - } - tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) - } - for i := range tracks { - tracks[i].TotalDiscs = totalDiscs - } - - return &AlbumResponsePayload{ - AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album), - TrackList: tracks, - }, nil -} - -func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { - playlist, err := t.getPlaylist(resourceID) - if err != nil { - return nil, err - } - - const pageSize = 50 - offset := 0 - totalTracks := playlist.NumberOfTracks - tracks := make([]AlbumTrackMetadata, 0, totalTracks) - - for { - page, pageErr := t.getPlaylistItemsPage(resourceID, offset, pageSize) - if pageErr != nil { - return nil, pageErr - } - if totalTracks == 0 && page.TotalNumberOfItems > 0 { - totalTracks = page.TotalNumberOfItems - } - - for _, item := range page.Items { - if item.Type != "track" { - continue - } - tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&item.Item)) - } - - if len(page.Items) == 0 || offset+len(page.Items) >= totalTracks || len(page.Items) < pageSize { - break - } - offset += len(page.Items) - } - - var info PlaylistInfoMetadata - info.Tracks.Total = totalTracks - info.Name = strings.TrimSpace(playlist.Title) - info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "origin") - info.Owner.DisplayName = tidalPlaylistOwnerName(playlist) - info.Owner.Name = strings.TrimSpace(playlist.Title) - info.Owner.Images = info.Images - - return &PlaylistResponsePayload{ - PlaylistInfo: info, - TrackList: tracks, - }, nil -} - -func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { - page, err := t.getArtistPage(resourceID) - if err != nil { - return nil, err - } - - headerModule := findTidalArtistPageModule(page, "ARTIST_HEADER") - albumsModule := findTidalArtistPageModule(page, "ALBUM_LIST") - if headerModule == nil { - return nil, fmt.Errorf("tidal artist page missing artist header") - } - if albumsModule == nil { - return nil, fmt.Errorf("tidal artist page missing albums list") - } - - albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems) - seenAlbumIDs := make(map[string]struct{}) - - appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) { - mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType) - if mapped.ID == "" { - return - } - if _, exists := seenAlbumIDs[mapped.ID]; exists { - return - } - seenAlbumIDs[mapped.ID] = struct{}{} - albums = append(albums, mapped) - } - - for rowIndex := range page.Rows { - for moduleIndex := range page.Rows[rowIndex].Modules { - module := &page.Rows[rowIndex].Modules[moduleIndex] - if module.Type != "ALBUM_LIST" { - continue - } - - fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title) - for _, album := range module.PagedList.Items { - appendArtistAlbum(album, fallbackType) - } - - pageSize := module.PagedList.Limit - if pageSize <= 0 { - pageSize = 50 - } - offset := len(module.PagedList.Items) - for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" { - albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize) - if pageErr != nil { - return nil, pageErr - } - - for _, album := range albumsPage.Items { - appendArtistAlbum(album, fallbackType) - } - - if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems { - break - } - offset += len(albumsPage.Items) - } - } - } - - return &ArtistResponsePayload{ - ArtistInfo: ArtistInfoMetadata{ - ID: tidalPrefixedNumericID(headerModule.Artist.ID), - Name: strings.TrimSpace(headerModule.Artist.Name), - Images: tidalImageURL(headerModule.Artist.Picture, "750x750"), - }, - Albums: albums, - }, nil -} - -type TidalDownloadInfo struct { - URL string - BitDepth int - SampleRate int -} - -type tidalAPIResult struct { - apiURL string - info TidalDownloadInfo - err error - duration time.Duration -} - -const ( - tidalAPITimeoutMobile = 25 * time.Second - tidalMaxRetries = 2 - tidalRetryDelay = 500 * time.Millisecond -) - -func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) { - var lastErr error - retryDelay := tidalRetryDelay - - for attempt := 0; attempt <= tidalMaxRetries; attempt++ { - if attempt > 0 { - GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay) - time.Sleep(retryDelay) - retryDelay *= 2 - } - - client := NewHTTPClientWithTimeout(timeout) - reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - lastErr = err - continue - } - - resp, err := client.Do(req) - if err != nil { - lastErr = err - errStr := strings.ToLower(err.Error()) - if strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "reset") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "eof") { - continue - } - break - } - if resp.StatusCode >= 500 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) - continue - } - - if resp.StatusCode == 429 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second - continue - } - - if resp.StatusCode != 200 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - lastErr = err - continue - } - - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - if v2Response.Data.AssetPresentation == "PREVIEW" { - return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL") - } - - return TidalDownloadInfo{ - URL: "MANIFEST:" + v2Response.Data.Manifest, - BitDepth: v2Response.Data.BitDepth, - SampleRate: v2Response.Data.SampleRate, - }, nil - } - - var v1Responses []struct { - OriginalTrackURL string `json:"OriginalTrackUrl"` - } - if err := json.Unmarshal(body, &v1Responses); err == nil { - for _, item := range v1Responses { - if item.OriginalTrackURL != "" { - return TidalDownloadInfo{ - URL: item.OriginalTrackURL, - BitDepth: 16, - SampleRate: 44100, - }, nil - } - } - } - - return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response") - } - - if lastErr != nil { - return TidalDownloadInfo{}, lastErr - } - return TidalDownloadInfo{}, fmt.Errorf("all retries failed") -} - -func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { - if len(apis) == 0 { - return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") - } - - GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) - - resultChan := make(chan tidalAPIResult, len(apis)) - startTime := time.Now() - - for _, apiURL := range apis { - go func(api string) { - reqStart := time.Now() - info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile) - resultChan <- tidalAPIResult{ - apiURL: api, - info: info, - err: err, - duration: time.Since(reqStart), - } - }(apiURL) - } - - var errors []string - - for i := 0; i < len(apis); i++ { - result := <-resultChan - if result.err == nil { - GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n", - result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration) - - go func(remaining int) { - for j := 0; j < remaining; j++ { - <-resultChan - } - }(len(apis) - i - 1) - - GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) - return result.apiURL, result.info, nil - } - errMsg := result.err.Error() - if len(errMsg) > 50 { - errMsg = errMsg[:50] + "..." - } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) - } - - GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime)) - return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) -} - -func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) { - apis := t.GetAvailableAPIs() - if len(apis) == 0 { - return TidalDownloadInfo{}, fmt.Errorf("no API URL configured") - } - - _, info, err := getDownloadURLParallel(apis, trackID, quality) - if err != nil { - return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err) - } - - return info, nil -} - -func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { - manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) - if err != nil { - return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) - } - - manifestStr := string(manifestBytes) - - manifestPreview := manifestStr - if len(manifestPreview) > 500 { - manifestPreview = manifestPreview[:500] + "..." - } - GoLog("[Tidal] Manifest content: %s\n", manifestPreview) - - if strings.HasPrefix(manifestStr, "{") { - var btsManifest TidalBTSManifest - if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { - return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) - } - - if len(btsManifest.URLs) == 0 { - return "", "", nil, fmt.Errorf("no URLs in BTS manifest") - } - - return btsManifest.URLs[0], "", nil, nil - } - - var mpd MPD - if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { - return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err) - } - - segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate - initURL = segTemplate.Initialization - mediaTemplate := segTemplate.Media - - if initURL == "" || mediaTemplate == "" { - initRe := regexp.MustCompile(`initialization="([^"]+)"`) - mediaRe := regexp.MustCompile(`media="([^"]+)"`) - - if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 { - initURL = match[1] - } - if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { - mediaTemplate = match[1] - } - } - - if initURL == "" { - return "", "", nil, fmt.Errorf("no initialization URL found in manifest") - } - - initURL = strings.ReplaceAll(initURL, "&", "&") - mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") - - segmentCount := 0 - GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments)) - for i, seg := range segTemplate.Timeline.Segments { - GoLog("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat) - segmentCount += seg.Repeat + 1 - } - GoLog("[Tidal] Segment count from XML: %d\n", segmentCount) - - if segmentCount == 0 { - fmt.Println("[Tidal] No segments from XML, trying regex...") - segRe := regexp.MustCompile(` 2 && match[2] != "" { - fmt.Sscanf(match[2], "%d", &repeat) - } - if i < 5 || i == len(matches)-1 { - GoLog("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat) - } - segmentCount += repeat + 1 - } - GoLog("[Tidal] Total segments from regex: %d\n", segmentCount) - } - - if segmentCount == 0 { - return "", "", nil, fmt.Errorf("no segments found in manifest") - } - - for i := 1; i <= segmentCount; i++ { - mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) - mediaURLs = append(mediaURLs, mediaURL) - } - - return "", initURL, mediaURLs, nil -} - -func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { - ctx := context.Background() - - if strings.HasPrefix(downloadURL, "MANIFEST:") { - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID) - } - - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := DoRequestWithUserAgent(t.client, req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return err - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - - var written int64 - if itemID != "" { - progressWriter := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(progressWriter, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush buffer: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - return nil -} - -func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error { - fmt.Println("[Tidal] Parsing manifest...") - directURL, initURL, mediaURLs, err := parseManifest(manifestB64) - if err != nil { - GoLog("[Tidal] Manifest parse error: %v\n", err) - return fmt.Errorf("failed to parse manifest: %w", err) - } - GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", - directURL != "", initURL != "", len(mediaURLs)) - - client := NewHTTPClientWithTimeout(DownloadTimeout) - - if directURL != "" { - GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil) - if err != nil { - GoLog("[Tidal] BTS request creation failed: %v\n", err) - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - GoLog("[Tidal] BTS download failed: %v\n", err) - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - GoLog("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode) - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength) - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - - var written int64 - if itemID != "" { - progressWriter := NewItemProgressWriter(out, itemID) - written, err = io.Copy(progressWriter, resp.Body) - } else { - written, err = io.Copy(out, resp.Body) - } - - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - return nil - } - - var m4aPath string - if strings.HasSuffix(outputPath, ".m4a") { - m4aPath = outputPath - } else if strings.HasSuffix(outputPath, ".flac") { - m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a" - } else { - m4aPath = outputPath - } - GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) - - out, err := openOutputForWrite(m4aPath, outputFD) - if err != nil { - GoLog("[Tidal] Failed to create M4A file: %v\n", err) - return fmt.Errorf("failed to create M4A file: %w", err) - } - - GoLog("[Tidal] Downloading init segment...\n") - if isDownloadCancelled(itemID) { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - return ErrDownloadCancelled - } - req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil) - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - GoLog("[Tidal] Init segment request failed: %v\n", err) - return fmt.Errorf("failed to create init segment request: %w", err) - } - resp, err := client.Do(req) - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - GoLog("[Tidal] Init segment download failed: %v\n", err) - return fmt.Errorf("failed to download init segment: %w", err) - } - if resp.StatusCode != 200 { - resp.Body.Close() - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode) - return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) - } - _, err = io.Copy(out, resp.Body) - resp.Body.Close() - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - GoLog("[Tidal] Init segment write failed: %v\n", err) - return fmt.Errorf("failed to write init segment: %w", err) - } - - totalSegments := len(mediaURLs) - for i, mediaURL := range mediaURLs { - if isDownloadCancelled(itemID) { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - return ErrDownloadCancelled - } - - if i%10 == 0 || i == totalSegments-1 { - GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments) - } - - if itemID != "" { - progress := float64(i+1) / float64(totalSegments) - SetItemProgress(itemID, progress, 0, 0) - } - - req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil) - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err) - return fmt.Errorf("failed to create segment %d request: %w", i+1, err) - } - resp, err := client.Do(req) - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err) - return fmt.Errorf("failed to download segment %d: %w", i+1, err) - } - if resp.StatusCode != 200 { - resp.Body.Close() - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode) - return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) - } - _, err = io.Copy(out, resp.Body) - resp.Body.Close() - if err != nil { - out.Close() - cleanupOutputOnError(m4aPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err) - return fmt.Errorf("failed to write segment %d: %w", i+1, err) - } - } - - if err := out.Close(); err != nil { - cleanupOutputOnError(m4aPath, outputFD) - GoLog("[Tidal] Failed to close M4A file: %v\n", err) - return fmt.Errorf("failed to close M4A file: %w", err) - } - - GoLog("[Tidal] DASH download completed: %s\n", m4aPath) - return nil -} - -type TidalDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - Copyright string - LyricsLRC string // LRC content for embedding in converted files -} - -func artistsMatch(spotifyArtist, tidalArtist string) bool { - normSpotify := normalizeLooseArtistName(spotifyArtist) - normTidal := normalizeLooseArtistName(tidalArtist) - - if normSpotify == normTidal { - return true - } - - if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { - return true - } - - spotifyArtists := splitArtists(normSpotify) - tidalArtists := splitArtists(normTidal) - - for _, exp := range spotifyArtists { - for _, fnd := range tidalArtists { - if exp == fnd { - return true - } - if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { - return true - } - if sameWordsUnordered(exp, fnd) { - GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) - return true - } - } - } - - spotifyLatin := isLatinScript(spotifyArtist) - tidalLatin := isLatinScript(tidalArtist) - if spotifyLatin != tidalLatin { - GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) - return true - } - - return false -} - -func splitArtists(artists string) []string { - normalized := artists - normalized = strings.ReplaceAll(normalized, " feat. ", "|") - normalized = strings.ReplaceAll(normalized, " feat ", "|") - normalized = strings.ReplaceAll(normalized, " ft. ", "|") - normalized = strings.ReplaceAll(normalized, " ft ", "|") - normalized = strings.ReplaceAll(normalized, " & ", "|") - normalized = strings.ReplaceAll(normalized, " and ", "|") - normalized = strings.ReplaceAll(normalized, ", ", "|") - normalized = strings.ReplaceAll(normalized, " x ", "|") - - parts := strings.Split(normalized, "|") - result := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - -func sameWordsUnordered(a, b string) bool { - wordsA := strings.Fields(a) - wordsB := strings.Fields(b) - - if len(wordsA) != len(wordsB) || len(wordsA) == 0 { - return false - } - - sortedA := make([]string, len(wordsA)) - sortedB := make([]string, len(wordsB)) - copy(sortedA, wordsA) - copy(sortedB, wordsB) - - for i := 0; i < len(sortedA)-1; i++ { - for j := i + 1; j < len(sortedA); j++ { - if sortedA[i] > sortedA[j] { - sortedA[i], sortedA[j] = sortedA[j], sortedA[i] - } - if sortedB[i] > sortedB[j] { - sortedB[i], sortedB[j] = sortedB[j], sortedB[i] - } - } - } - - for i := range sortedA { - if sortedA[i] != sortedB[i] { - return false - } - } - return true -} - -func titlesMatch(expectedTitle, foundTitle string) bool { - normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) - normFound := strings.ToLower(strings.TrimSpace(foundTitle)) - - if normExpected == normFound { - return true - } - - if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { - return true - } - - cleanExpected := cleanTitle(normExpected) - cleanFound := cleanTitle(normFound) - - if cleanExpected == cleanFound { - return true - } - - if cleanExpected != "" && cleanFound != "" { - if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { - return true - } - } - - coreExpected := extractCoreTitle(normExpected) - coreFound := extractCoreTitle(normFound) - - if coreExpected != "" && coreFound != "" && coreExpected == coreFound { - return true - } - - looseExpected := normalizeLooseTitle(normExpected) - looseFound := normalizeLooseTitle(normFound) - if looseExpected != "" && looseFound != "" { - if looseExpected == looseFound { - return true - } - if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { - return true - } - } - - if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && - strings.TrimSpace(expectedTitle) != "" && - strings.TrimSpace(foundTitle) != "" { - expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) - foundSymbols := normalizeSymbolOnlyTitle(foundTitle) - if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { - GoLog("[Tidal] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true - } - GoLog("[Tidal] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) - return false - } - - expectedLatin := isLatinScript(expectedTitle) - foundLatin := isLatinScript(foundTitle) - if expectedLatin != foundLatin { - GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true - } - - return false -} - -func extractCoreTitle(title string) string { - parenIdx := strings.Index(title, "(") - bracketIdx := strings.Index(title, "[") - dashIdx := strings.Index(title, " - ") - - cutIdx := len(title) - if parenIdx > 0 && parenIdx < cutIdx { - cutIdx = parenIdx - } - if bracketIdx > 0 && bracketIdx < cutIdx { - cutIdx = bracketIdx - } - if dashIdx > 0 && dashIdx < cutIdx { - cutIdx = dashIdx - } - - return strings.TrimSpace(title[:cutIdx]) -} - -func cleanTitle(title string) string { - cleaned := title - - versionPatterns := []string{ - "remaster", "remastered", "deluxe", "bonus", "single", - "album version", "radio edit", "original mix", "extended", - "club mix", "remix", "live", "acoustic", "demo", - } - - for { - startParen := strings.LastIndex(cleaned, "(") - endParen := strings.LastIndex(cleaned, ")") - if startParen >= 0 && endParen > startParen { - content := strings.ToLower(cleaned[startParen+1 : endParen]) - isVersionIndicator := false - for _, pattern := range versionPatterns { - if strings.Contains(content, pattern) { - isVersionIndicator = true - break - } - } - if isVersionIndicator { - cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:] - continue - } - } - break - } - - for { - startBracket := strings.LastIndex(cleaned, "[") - endBracket := strings.LastIndex(cleaned, "]") - if startBracket >= 0 && endBracket > startBracket { - content := strings.ToLower(cleaned[startBracket+1 : endBracket]) - isVersionIndicator := false - for _, pattern := range versionPatterns { - if strings.Contains(content, pattern) { - isVersionIndicator = true - break - } - } - if isVersionIndicator { - cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:] - continue - } - } - break - } - - dashPatterns := []string{ - " - remaster", " - remastered", " - single version", " - radio edit", - " - live", " - acoustic", " - demo", " - remix", - } - for _, pattern := range dashPatterns { - if strings.HasSuffix(strings.ToLower(cleaned), pattern) { - cleaned = cleaned[:len(cleaned)-len(pattern)] - } - } - - for strings.Contains(cleaned, " ") { - cleaned = strings.ReplaceAll(cleaned, " ", " ") - } - - return strings.TrimSpace(cleaned) -} - -func isLatinScript(s string) bool { - for _, r := range s { - if r < 128 { - continue - } - if (r >= 0x0100 && r <= 0x024F) || - (r >= 0x1E00 && r <= 0x1EFF) || - (r >= 0x00C0 && r <= 0x00FF) { - continue - } - if (r >= 0x4E00 && r <= 0x9FFF) || - (r >= 0x3040 && r <= 0x309F) || - (r >= 0x30A0 && r <= 0x30FF) || - (r >= 0xAC00 && r <= 0xD7AF) || - (r >= 0x0600 && r <= 0x06FF) || - (r >= 0x0400 && r <= 0x04FF) { - return false - } - } - return true -} - -func parseTidalRequestTrackID(raw string) (int64, bool) { - trimmed := strings.TrimSpace(raw) - trimmed = strings.TrimPrefix(trimmed, "tidal:") - if trimmed == "" { - return 0, false - } - - trackID, err := strconv.ParseInt(trimmed, 10, 64) - if err != nil || trackID <= 0 { - return 0, false - } - return trackID, true -} - -func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) { - if downloader == nil { - downloader = NewTidalDownloader() - } - if strings.TrimSpace(logPrefix) == "" { - logPrefix = "Tidal" - } - - expectedDurationSec := req.DurationMS / 1000 - var trackID int64 - var gotTidalID bool - var resolvedViaSongLink bool - - if req.TidalID != "" { - GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) - if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok { - trackID = parsedTrackID - gotTidalID = true - } - } - - if !gotTidalID && req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { - GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.TidalTrackID) - trackID = cached.TidalTrackID - gotTidalID = true - } - } - - if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" { - GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix) - searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC( - req.TrackName, - req.ArtistName, - req.AlbumName, - req.ISRC, - expectedDurationSec, - ) - if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 { - trackID = searchTrack.ID - gotTidalID = true - GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID) - } else if searchErr != nil { - GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr) - } - } - - if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") { - GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix) - - resolveFromAvailability := func(availability *TrackAvailability) { - if availability == nil || gotTidalID { - return - } - if availability.TidalID != "" { - if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok { - trackID = parsedTrackID - GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) - gotTidalID = true - resolvedViaSongLink = true - return - } - } - if availability.TidalURL != "" { - var idErr error - trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) - if idErr == nil && trackID > 0 { - GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) - gotTidalID = true - resolvedViaSongLink = true - } - } - } - - if req.DeezerID != "" { - GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID) - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckAvailabilityFromDeezer(req.DeezerID) - if slErr == nil { - resolveFromAvailability(availability) - } else { - GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) - } - } - - if !gotTidalID && req.SpotifyID != "" { - if strings.HasPrefix(req.SpotifyID, "deezer:") { - deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") - GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) - if slErr == nil { - resolveFromAvailability(availability) - } else { - GoLog("[%s] SongLink Deezer lookup failed: %v\n", logPrefix, slErr) - } - } - } - - if !gotTidalID && req.SpotifyID != "" && !strings.HasPrefix(req.SpotifyID, "deezer:") { - songlink := NewSongLinkClient() - availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - if slErr == nil { - resolveFromAvailability(availability) - } - } - } - - if !gotTidalID || trackID <= 0 { - return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") - } - - actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10)) - if fetchErr != nil { - GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) - } else { - providerArtist := actualTrack.Artist.Name - if providerArtist == "" && len(actualTrack.Artists) > 0 { - providerArtist = actualTrack.Artists[0].Name - } - resolved := resolvedTrackInfo{ - Title: actualTrack.Title, - ArtistName: providerArtist, - ISRC: strings.TrimSpace(actualTrack.ISRC), - Duration: actualTrack.Duration, - SkipNameVerification: resolvedViaSongLink, - } - if !trackMatchesRequest(req, resolved, logPrefix) { - if req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, 0) - } - return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'", - trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) - } - GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title) - } - - // Use track_number / disc_number from the actual Tidal API data when the - // request doesn't carry them (e.g. downloads from search results / popular). - resolvedTrackNumber := req.TrackNumber - resolvedDiscNumber := req.DiscNumber - if actualTrack != nil { - if resolvedTrackNumber == 0 && actualTrack.TrackNumber > 0 { - resolvedTrackNumber = actualTrack.TrackNumber - } - if resolvedDiscNumber == 0 && actualTrack.VolumeNumber > 0 { - resolvedDiscNumber = actualTrack.VolumeNumber - } - } - - track := &TidalTrack{ - ID: trackID, - Title: strings.TrimSpace(req.TrackName), - ISRC: strings.TrimSpace(req.ISRC), - Duration: expectedDurationSec, - TrackNumber: resolvedTrackNumber, - VolumeNumber: resolvedDiscNumber, - } - track.Artist.Name = strings.TrimSpace(req.ArtistName) - track.Album.Title = strings.TrimSpace(req.AlbumName) - track.Album.ReleaseDate = strings.TrimSpace(req.ReleaseDate) - - if req.ISRC != "" { - GetTrackIDCache().SetTidal(req.ISRC, trackID) - } - return track, nil -} - -func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { - downloader := NewTidalDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - track, err := resolveTidalTrackForRequest(req, downloader, "Tidal") - if err != nil { - return TidalDownloadResult{}, err - } - - quality := req.Quality - if quality == "" || quality == "DEFAULT" { - quality = "LOSSLESS" - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - - outputExt := strings.TrimSpace(req.OutputExt) - if outputExt == "" { - if quality == "HIGH" { - outputExt = ".m4a" - } else { - outputExt = ".flac" - } - } else if !strings.HasPrefix(outputExt, ".") { - outputExt = "." + outputExt - } - - var outputPath string - var m4aPath string - if isSafOutput { - outputPath = strings.TrimSpace(req.OutputPath) - if outputPath == "" && isFDOutput(req.OutputFD) { - outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) - } - m4aPath = outputPath - } else { - if outputExt == ".m4a" || quality == "HIGH" { - filename = sanitizeFilename(filename) + ".m4a" - outputPath = filepath.Join(req.OutputDir, filename) - m4aPath = outputPath - } else { - filename = sanitizeFilename(filename) + ".flac" - outputPath = filepath.Join(req.OutputDir, filename) - m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a" - } - - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - if quality != "HIGH" { - if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil - } - } - } - - if !isSafOutput { - tmpPath := outputPath + ".m4a.tmp" - if _, err := os.Stat(tmpPath); err == nil { - GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) - os.Remove(tmpPath) - } - } - - GoLog("[Tidal] Using quality: %s\n", quality) - - downloadInfo, err := downloader.GetDownloadURL(track.ID, quality) - if err != nil { - return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) - } - - GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) - - var parallelResult *ParallelDownloadResult - parallelDone := make(chan struct{}) - go func() { - defer close(parallelDone) - coverURL := req.CoverURL - embedLyrics := req.EmbedLyrics - if !req.EmbedMetadata { - coverURL = "" - embedLyrics = false - } - parallelResult = FetchCoverAndLyricsParallel( - coverURL, - req.EmbedMaxQualityCover, - req.SpotifyID, - req.TrackName, - req.ArtistName, - embedLyrics, - int64(req.DurationMS), - ) - }() - - GoLog("[Tidal] Starting download to: %s\n", outputPath) - GoLog("[Tidal] Download URL type: %s\n", func() string { - if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { - return "MANIFEST (DASH/BTS)" - } - return "Direct URL" - }()) - - if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return TidalDownloadResult{}, ErrDownloadCancelled - } - GoLog("[Tidal] Download failed with error: %v\n", err) - return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - fmt.Println("[Tidal] Download completed successfully") - - <-parallelDone - - if req.ItemID != "" { - SetItemProgress(req.ItemID, 1.0, 0, 0) - SetItemFinalizing(req.ItemID) - } - - actualOutputPath := outputPath - if !isSafOutput { - if _, err := os.Stat(m4aPath); err == nil { - actualOutputPath = m4aPath - GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) - } else if _, err := os.Stat(outputPath); err != nil { - return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) - } - } - - releaseDate := req.ReleaseDate - if releaseDate == "" && track.Album.ReleaseDate != "" { - releaseDate = track.Album.ReleaseDate - GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) - } - - actualTrackNumber := req.TrackNumber - actualDiscNumber := req.DiscNumber - if actualTrackNumber == 0 { - actualTrackNumber = track.TrackNumber - } - if actualDiscNumber == 0 { - actualDiscNumber = track.VolumeNumber - } - copyright := strings.TrimSpace(req.Copyright) - if copyright == "" { - copyright = strings.TrimSpace(track.Copyright) - } - - metadata := Metadata{ - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - AlbumArtist: req.AlbumArtist, - ArtistTagMode: req.ArtistTagMode, - Date: releaseDate, - TrackNumber: actualTrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: actualDiscNumber, - TotalDiscs: req.TotalDiscs, - ISRC: track.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: copyright, - Composer: req.Composer, - } - - var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil { - coverData = parallelResult.CoverData - GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) - } - - actualExt := outputExt - if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { - actualExt = ".m4a" - } - if actualExt == "" && !isSafOutput { - actualExt = strings.ToLower(filepath.Ext(actualOutputPath)) - } - - if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) { - if req.EmbedMetadata { - if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) - } - } else { - GoLog("[Tidal] Metadata embedding disabled by settings, skipping FLAC metadata/lyrics embedding\n") - } - - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { - GoLog("[Tidal] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Tidal] LRC file saved: %s\n", lrcPath) - } - } - - if lyricsMode == "embed" || lyricsMode == "both" { - GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Tidal] Lyrics embedded successfully") - } - } - } else if req.EmbedMetadata && req.EmbedLyrics { - fmt.Println("[Tidal] No lyrics available from parallel fetch") - } - } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { - if quality == "HIGH" { - GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { - GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode) - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Tidal] LRC file saved: %s\n", lrcPath) - } - } - } - } else { - fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") - } - } - - if !isSafOutput { - AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) - } - - bitDepth := downloadInfo.BitDepth - sampleRate := downloadInfo.SampleRate - if quality == "HIGH" { - bitDepth = 0 - sampleRate = 44100 - } - lyricsLRC := "" - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - } - - resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( - req, - track.Album.Title, - track.Album.ReleaseDate, - actualTrackNumber, - actualDiscNumber, - ) - - return TidalDownloadResult{ - FilePath: actualOutputPath, - BitDepth: bitDepth, - SampleRate: sampleRate, - Title: track.Title, - Artist: track.Artist.Name, - Album: resultAlbum, - ReleaseDate: resultReleaseDate, - TrackNumber: resultTrackNumber, - DiscNumber: resultDiscNumber, - ISRC: track.ISRC, - Copyright: copyright, - LyricsLRC: lyricsLRC, - }, nil -} - -func parseTidalURL(input string) (string, string, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "", "", fmt.Errorf("empty URL") - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return "", "", err - } - - if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" { - return "", "", fmt.Errorf("not a Tidal URL") - } - - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - - if len(parts) > 0 && parts[0] == "browse" { - parts = parts[1:] - } - - if len(parts) < 2 { - return "", "", fmt.Errorf("invalid Tidal URL format") - } - - resourceType := parts[0] - resourceID := parts[1] - - switch resourceType { - case "track", "album", "artist", "playlist": - return resourceType, resourceID, nil - default: - return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType) - } -} diff --git a/go_backend/tidal_test.go b/go_backend/tidal_test.go deleted file mode 100644 index bc8a56dc..00000000 --- a/go_backend/tidal_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package gobackend - -import "testing" - -func TestParseTidalURL(t *testing.T) { - tests := []struct { - name string - input string - wantType string - wantID string - expectErr bool - }{ - { - name: "track url", - input: "https://tidal.com/track/77616174", - wantType: "track", - wantID: "77616174", - }, - { - name: "browse album url", - input: "https://listen.tidal.com/browse/album/77616169", - wantType: "album", - wantID: "77616169", - }, - { - name: "artist url", - input: "https://www.tidal.com/artist/3852143", - wantType: "artist", - wantID: "3852143", - }, - { - name: "playlist url", - input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b", - wantType: "playlist", - wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b", - }, - { - name: "unsupported host", - input: "https://example.com/track/123", - expectErr: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - gotType, gotID, err := parseTidalURL(test.input) - if test.expectErr { - if err == nil { - t.Fatalf("expected error, got none") - } - return - } - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if gotType != test.wantType || gotID != test.wantID { - t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) - } - }) - } -} - -func TestParseTidalRequestTrackID(t *testing.T) { - tests := []struct { - input string - want int64 - ok bool - }{ - {input: "40681594", want: 40681594, ok: true}, - {input: "tidal:40681594", want: 40681594, ok: true}, - {input: " tidal:40681594 ", want: 40681594, ok: true}, - {input: "", want: 0, ok: false}, - {input: "tidal:not-a-number", want: 0, ok: false}, - } - - for _, test := range tests { - got, ok := parseTidalRequestTrackID(test.input) - if got != test.want || ok != test.ok { - t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok) - } - } -} - -func TestTidalImageURL(t *testing.T) { - got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280") - want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg" - if got != want { - t.Fatalf("tidalImageURL() = %q, want %q", got, want) - } -} - -func TestTidalTrackToTrackMetadata(t *testing.T) { - track := &TidalTrack{ - ID: 77616174, - Title: "Bruckner: Symphony No. 5", - ISRC: "GBUM71507433", - Duration: 1172, - TrackNumber: 5, - VolumeNumber: 1, - URL: "http://www.tidal.com/track/77616174", - } - track.Artist.ID = 3852143 - track.Artist.Name = "Staatskapelle Berlin" - track.Artists = []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Picture string `json:"picture"` - }{ - {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, - {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, - } - track.Album.ID = 77616169 - track.Album.Title = "Bruckner: Symphonies 4-9" - track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3" - track.Album.ReleaseDate = "2016-02-26" - - got := tidalTrackToTrackMetadata(track) - if got.SpotifyID != "tidal:77616174" { - t.Fatalf("unexpected track ID: %q", got.SpotifyID) - } - if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { - t.Fatalf("unexpected artists: %q", got.Artists) - } - if got.AlbumID != "tidal:77616169" { - t.Fatalf("unexpected album ID: %q", got.AlbumID) - } - if got.ArtistID != "tidal:3852143" { - t.Fatalf("unexpected artist ID: %q", got.ArtistID) - } - if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" { - t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL) - } -} - -func TestTidalAlbumToArtistAlbum(t *testing.T) { - album := &tidalPublicAlbum{ - ID: 77616169, - Title: "Bruckner: Symphonies 4-9", - Type: "ALBUM", - Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", - ReleaseDate: "2016-02-26", - NumberOfTracks: 23, - Artists: []tidalPublicArtist{ - {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"}, - {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"}, - }, - } - - got := tidalAlbumToArtistAlbum(album) - if got.ID != "tidal:77616169" { - t.Fatalf("unexpected album ID: %q", got.ID) - } - if got.AlbumType != "album" { - t.Fatalf("unexpected album type: %q", got.AlbumType) - } - if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" { - t.Fatalf("unexpected artists: %q", got.Artists) - } - if got.Images == "" { - t.Fatalf("expected image URL, got empty string") - } -} - -func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) { - album := &tidalPublicAlbum{ - ID: 490623904, - Title: "LET 'EM KNOW", - Cover: "fc18a64b-d76b-4582-962a-224cb05193f3", - NumberOfTracks: 1, - } - - got := tidalAlbumToArtistAlbumWithType(album, "single") - if got.AlbumType != "single" { - t.Fatalf("unexpected fallback album type: %q", got.AlbumType) - } -} - -func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) { - tests := []struct { - title string - want string - }{ - {title: "Albums", want: "album"}, - {title: "EP & Singles", want: "single"}, - {title: "Compilations", want: "album"}, - {title: "Appears On", want: "album"}, - {title: "Unknown", want: ""}, - } - - for _, test := range tests { - if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want { - t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want) - } - } -} - -func TestTidalPlaylistImageUsesOrigin(t *testing.T) { - got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin") - want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg" - if got != want { - t.Fatalf("unexpected origin playlist image URL: %q", got) - } -} - -func TestTidalPlaylistOwnerName(t *testing.T) { - editorial := &tidalPublicPlaylist{Type: "EDITORIAL"} - if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" { - t.Fatalf("unexpected editorial owner: %q", got) - } - - artist := &tidalPublicPlaylist{Type: "ARTIST"} - if got := tidalPlaylistOwnerName(artist); got != "Artist" { - t.Fatalf("unexpected artist owner: %q", got) - } - - user := &tidalPublicPlaylist{} - user.Creator.Name = "djtest" - if got := tidalPlaylistOwnerName(user); got != "djtest" { - t.Fatalf("unexpected creator owner: %q", got) - } -} diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 3976d6f0..00da7e08 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -107,6 +107,263 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } +func artistsMatch(expectedArtist, foundArtist string) bool { + normExpected := normalizeLooseArtistName(expectedArtist) + normFound := normalizeLooseArtistName(foundArtist) + + if normExpected == normFound { + return true + } + + if strings.Contains(normExpected, normFound) || + strings.Contains(normFound, normExpected) { + return true + } + + expectedArtists := splitArtists(normExpected) + foundArtists := splitArtists(normFound) + + for _, expected := range expectedArtists { + for _, found := range foundArtists { + if expected == found { + return true + } + if strings.Contains(expected, found) || + strings.Contains(found, expected) { + return true + } + if sameWordsUnordered(expected, found) { + return true + } + } + } + + if isLatinScript(expectedArtist) != isLatinScript(foundArtist) { + return true + } + + return false +} + +func splitArtists(artists string) []string { + normalized := artists + normalized = strings.ReplaceAll(normalized, " feat. ", "|") + normalized = strings.ReplaceAll(normalized, " feat ", "|") + normalized = strings.ReplaceAll(normalized, " ft. ", "|") + normalized = strings.ReplaceAll(normalized, " ft ", "|") + normalized = strings.ReplaceAll(normalized, " & ", "|") + normalized = strings.ReplaceAll(normalized, " and ", "|") + normalized = strings.ReplaceAll(normalized, ", ", "|") + normalized = strings.ReplaceAll(normalized, " x ", "|") + + parts := strings.Split(normalized, "|") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func sameWordsUnordered(a, b string) bool { + wordsA := strings.Fields(a) + wordsB := strings.Fields(b) + if len(wordsA) != len(wordsB) || len(wordsA) == 0 { + return false + } + + sortedA := make([]string, len(wordsA)) + sortedB := make([]string, len(wordsB)) + copy(sortedA, wordsA) + copy(sortedB, wordsB) + + for i := 0; i < len(sortedA)-1; i++ { + for j := i + 1; j < len(sortedA); j++ { + if sortedA[i] > sortedA[j] { + sortedA[i], sortedA[j] = sortedA[j], sortedA[i] + } + if sortedB[i] > sortedB[j] { + sortedB[i], sortedB[j] = sortedB[j], sortedB[i] + } + } + } + + for i := range sortedA { + if sortedA[i] != sortedB[i] { + return false + } + } + return true +} + +func titlesMatch(expectedTitle, foundTitle string) bool { + normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) + normFound := strings.ToLower(strings.TrimSpace(foundTitle)) + + if normExpected == normFound { + return true + } + + if strings.Contains(normExpected, normFound) || + strings.Contains(normFound, normExpected) { + return true + } + + cleanExpected := cleanTitle(normExpected) + cleanFound := cleanTitle(normFound) + if cleanExpected == cleanFound { + return true + } + + if cleanExpected != "" && cleanFound != "" { + if strings.Contains(cleanExpected, cleanFound) || + strings.Contains(cleanFound, cleanExpected) { + return true + } + } + + coreExpected := extractCoreTitle(normExpected) + coreFound := extractCoreTitle(normFound) + if coreExpected != "" && coreFound != "" && coreExpected == coreFound { + return true + } + + looseExpected := normalizeLooseTitle(normExpected) + looseFound := normalizeLooseTitle(normFound) + if looseExpected != "" && looseFound != "" { + if looseExpected == looseFound { + return true + } + if strings.Contains(looseExpected, looseFound) || + strings.Contains(looseFound, looseExpected) { + return true + } + } + + if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && + strings.TrimSpace(expectedTitle) != "" && + strings.TrimSpace(foundTitle) != "" { + expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) + foundSymbols := normalizeSymbolOnlyTitle(foundTitle) + if expectedSymbols != "" && + foundSymbols != "" && + expectedSymbols == foundSymbols { + return true + } + } + + return false +} + +func extractCoreTitle(title string) string { + parenIdx := strings.Index(title, "(") + bracketIdx := strings.Index(title, "[") + dashIdx := strings.Index(title, " - ") + + cutIdx := len(title) + if parenIdx > 0 && parenIdx < cutIdx { + cutIdx = parenIdx + } + if bracketIdx > 0 && bracketIdx < cutIdx { + cutIdx = bracketIdx + } + if dashIdx > 0 && dashIdx < cutIdx { + cutIdx = dashIdx + } + + return strings.TrimSpace(title[:cutIdx]) +} + +func cleanTitle(title string) string { + cleaned := title + + versionPatterns := []string{ + "remaster", "remastered", "deluxe", "bonus", "single", + "album version", "radio edit", "original mix", "extended", + "club mix", "remix", "live", "acoustic", "demo", + } + + for { + startParen := strings.LastIndex(cleaned, "(") + endParen := strings.LastIndex(cleaned, ")") + if startParen >= 0 && endParen > startParen { + content := strings.ToLower(cleaned[startParen+1 : endParen]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:] + continue + } + } + break + } + + for { + startBracket := strings.LastIndex(cleaned, "[") + endBracket := strings.LastIndex(cleaned, "]") + if startBracket >= 0 && endBracket > startBracket { + content := strings.ToLower(cleaned[startBracket+1 : endBracket]) + isVersionIndicator := false + for _, pattern := range versionPatterns { + if strings.Contains(content, pattern) { + isVersionIndicator = true + break + } + } + if isVersionIndicator { + cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:] + continue + } + } + break + } + + dashPatterns := []string{ + " - remaster", " - remastered", " - single version", " - radio edit", + " - live", " - acoustic", " - demo", " - remix", + } + for _, pattern := range dashPatterns { + if strings.HasSuffix(strings.ToLower(cleaned), pattern) { + cleaned = cleaned[:len(cleaned)-len(pattern)] + } + } + + for strings.Contains(cleaned, " ") { + cleaned = strings.ReplaceAll(cleaned, " ", " ") + } + + return strings.TrimSpace(cleaned) +} + +func isLatinScript(value string) bool { + for _, r := range value { + if r < 128 { + continue + } + if (r >= 0x0100 && r <= 0x024F) || + (r >= 0x1E00 && r <= 0x1EFF) || + (r >= 0x00C0 && r <= 0x00FF) { + continue + } + if (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3040 && r <= 0x309F) || + (r >= 0x30A0 && r <= 0x30FF) || + (r >= 0xAC00 && r <= 0xD7AF) || + (r >= 0x0600 && r <= 0x06FF) || + (r >= 0x0400 && r <= 0x04FF) { + return false + } + } + return true +} + type resolvedTrackInfo struct { Title string ArtistName string diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 19db3434..846a0987 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -456,14 +456,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getTidalMetadata": - let args = call.arguments as! [String: Any] - let resourceType = args["resource_type"] as! String - let resourceId = args["resource_id"] as! String - let response = GobackendGetTidalMetadata(resourceType, resourceId, &error) - if let error = error { throw error } - return response - case "getProviderMetadata": let args = call.arguments as! [String: Any] let providerId = args["provider_id"] as! String @@ -480,13 +472,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "convertTidalToSpotifyDeezer": - let args = call.arguments as! [String: Any] - let url = args["url"] as! String - let response = GobackendConvertTidalToSpotifyDeezer(url, &error) - if let error = error { throw error } - return response - case "searchDeezerByISRC": let args = call.arguments as! [String: Any] let isrc = args["isrc"] as! String diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d28e121..d7b0fde6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -166,6 +166,18 @@ abstract class AppLocalizations { /// **'Paste a supported URL or search by name'** String get homeSubtitle; + /// Title shown on home when no providers are available yet + /// + /// In en, this message translates to: + /// **'Home is empty'** + String get homeEmptyTitle; + + /// Subtitle shown on home when no providers are available yet + /// + /// In en, this message translates to: + /// **'Install your first extension to unlock search and browsing.'** + String get homeEmptySubtitle; + /// Info text about supported URL types /// /// In en, this message translates to: @@ -5844,6 +5856,364 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Could not download update. Try again later.'** String get notifUpdateFailedBody; + + /// Search filter label - tracks + /// + /// In en, this message translates to: + /// **'Tracks'** + String get searchTracks; + + /// Default placeholder for the main search field on Home + /// + /// In en, this message translates to: + /// **'Paste supported URL or search...'** + String get homeSearchHintDefault; + + /// Placeholder for the main search field when a provider is selected + /// + /// In en, this message translates to: + /// **'Search with {providerName}...'** + String homeSearchHintProvider(String providerName); + + /// Tooltip for importing a CSV file into Home search + /// + /// In en, this message translates to: + /// **'Import CSV'** + String get homeImportCsvTooltip; + + /// Tooltip for the Home search provider picker + /// + /// In en, this message translates to: + /// **'Change search provider'** + String get homeChangeSearchProviderTooltip; + + /// Generic action - paste from clipboard + /// + /// In en, this message translates to: + /// **'Paste'** + String get actionPaste; + + /// Placeholder for the search screen input + /// + /// In en, this message translates to: + /// **'Search tracks...'** + String get searchTracksHint; + + /// Empty-state prompt on the search screen + /// + /// In en, this message translates to: + /// **'Search for tracks'** + String get searchTracksEmptyPrompt; + + /// Placeholder shown in the tutorial search demo + /// + /// In en, this message translates to: + /// **'Paste or search...'** + String get tutorialSearchHint; + + /// Accessibility label for completed download state in tutorial demo + /// + /// In en, this message translates to: + /// **'Download completed'** + String get tutorialDownloadCompletedSemantics; + + /// Accessibility label for active download state in tutorial demo + /// + /// In en, this message translates to: + /// **'Download in progress'** + String get tutorialDownloadInProgressSemantics; + + /// Accessibility label for idle download button in tutorial demo + /// + /// In en, this message translates to: + /// **'Start download'** + String get tutorialStartDownloadSemantics; + + /// Settings toggle title for writing metadata into downloaded files + /// + /// In en, this message translates to: + /// **'Embed Metadata'** + String get optionsEmbedMetadata; + + /// Subtitle when metadata embedding is enabled + /// + /// In en, this message translates to: + /// **'Write metadata, cover art, and embedded lyrics to files'** + String get optionsEmbedMetadataSubtitleOn; + + /// Subtitle when metadata embedding is disabled + /// + /// In en, this message translates to: + /// **'Disabled (advanced): skip all metadata embedding'** + String get optionsEmbedMetadataSubtitleOff; + + /// Subtitle for max quality cover when metadata embedding is disabled + /// + /// In en, this message translates to: + /// **'Disabled when metadata embedding is off'** + String get optionsMaxQualityCoverSubtitleDisabled; + + /// Example placeholder for the download filename format input + /// + /// In en, this message translates to: + /// **'{artist} - {title}'** + String downloadFilenameHintExample(Object artist, Object title); + + /// Message shown when a track file has no embedded cover art + /// + /// In en, this message translates to: + /// **'No embedded album art found'** + String get trackCoverNoEmbeddedArt; + + /// Button label for replacing selected cover art + /// + /// In en, this message translates to: + /// **'Replace Cover'** + String get trackCoverReplace; + + /// Button label for selecting cover art + /// + /// In en, this message translates to: + /// **'Pick Cover'** + String get trackCoverPick; + + /// Tooltip for clearing the newly selected cover art + /// + /// In en, this message translates to: + /// **'Clear selected cover'** + String get trackCoverClearSelected; + + /// Label for the currently embedded cover preview + /// + /// In en, this message translates to: + /// **'Current cover'** + String get trackCoverCurrent; + + /// Label for the newly selected cover preview + /// + /// In en, this message translates to: + /// **'Selected cover'** + String get trackCoverSelected; + + /// Notice shown when a new cover has been selected but not saved yet + /// + /// In en, this message translates to: + /// **'The selected cover will replace the current embedded cover when you tap Save.'** + String get trackCoverReplaceNotice; + + /// Generic action - stop + /// + /// In en, this message translates to: + /// **'Stop'** + String get actionStop; + + /// Accessibility label for a queue item that is finalizing + /// + /// In en, this message translates to: + /// **'Finalizing download'** + String get queueFinalizingDownload; + + /// Accessibility label when a downloaded file is missing from disk + /// + /// In en, this message translates to: + /// **'Downloaded file missing'** + String get queueDownloadedFileMissing; + + /// Accessibility label for completed download state in queue + /// + /// In en, this message translates to: + /// **'Download completed'** + String get queueDownloadCompleted; + + /// Accessibility label for picking an accent color + /// + /// In en, this message translates to: + /// **'Select accent color {hex}'** + String appearanceSelectAccentColor(String hex); + + /// Tooltip when auto-scroll is enabled on the log screen + /// + /// In en, this message translates to: + /// **'Auto-scroll ON'** + String get logAutoScrollOn; + + /// Tooltip when auto-scroll is disabled on the log screen + /// + /// In en, this message translates to: + /// **'Auto-scroll OFF'** + String get logAutoScrollOff; + + /// Tooltip for copying logs + /// + /// In en, this message translates to: + /// **'Copy logs'** + String get logCopyLogs; + + /// Tooltip for clearing the log search field + /// + /// In en, this message translates to: + /// **'Clear search'** + String get logClearSearch; + + /// Diagnostic badge label when ISP blocking is detected + /// + /// In en, this message translates to: + /// **'ISP BLOCKING DETECTED'** + String get logIssueIspBlockingLabel; + + /// Diagnostic badge description for ISP blocking + /// + /// In en, this message translates to: + /// **'Your ISP may be blocking access to download services'** + String get logIssueIspBlockingDescription; + + /// Diagnostic badge suggestion for ISP blocking + /// + /// In en, this message translates to: + /// **'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'** + String get logIssueIspBlockingSuggestion; + + /// Diagnostic badge label when the service rate limits requests + /// + /// In en, this message translates to: + /// **'RATE LIMITED'** + String get logIssueRateLimitedLabel; + + /// Diagnostic badge description for rate limiting + /// + /// In en, this message translates to: + /// **'Too many requests to the service'** + String get logIssueRateLimitedDescription; + + /// Diagnostic badge suggestion for rate limiting + /// + /// In en, this message translates to: + /// **'Wait a few minutes before trying again'** + String get logIssueRateLimitedSuggestion; + + /// Diagnostic badge label for generic network errors + /// + /// In en, this message translates to: + /// **'NETWORK ERROR'** + String get logIssueNetworkErrorLabel; + + /// Diagnostic badge description for generic network errors + /// + /// In en, this message translates to: + /// **'Connection issues detected'** + String get logIssueNetworkErrorDescription; + + /// Diagnostic badge suggestion for generic network errors + /// + /// In en, this message translates to: + /// **'Check your internet connection'** + String get logIssueNetworkErrorSuggestion; + + /// Diagnostic badge label when a track is unavailable + /// + /// In en, this message translates to: + /// **'TRACK NOT FOUND'** + String get logIssueTrackNotFoundLabel; + + /// Diagnostic badge description when a track is unavailable + /// + /// In en, this message translates to: + /// **'Some tracks could not be found on download services'** + String get logIssueTrackNotFoundDescription; + + /// Diagnostic badge suggestion when a track is unavailable + /// + /// In en, this message translates to: + /// **'The track may not be available in lossless quality'** + String get logIssueTrackNotFoundSuggestion; + + /// Snackbar shown while clickable artist metadata is being resolved + /// + /// In en, this message translates to: + /// **'Looking up artist...'** + String get clickableLookingUpArtist; + + /// Snackbar shown when clickable metadata cannot open a destination + /// + /// In en, this message translates to: + /// **'{type} information not available'** + String clickableInformationUnavailable(String type); + + /// Section title for extension tags + /// + /// In en, this message translates to: + /// **'Tags'** + String get extensionDetailsTags; + + /// Section title for extension metadata information + /// + /// In en, this message translates to: + /// **'Information'** + String get extensionDetailsInformation; + + /// Capability label for utility-only extensions + /// + /// In en, this message translates to: + /// **'Utility Functions'** + String get extensionUtilityFunctions; + + /// Generic action - dismiss + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get actionDismiss; + + /// Tooltip for editing the selected download folder + /// + /// In en, this message translates to: + /// **'Change folder'** + String get setupChangeFolderTooltip; + + /// Accessibility label for opening a track item + /// + /// In en, this message translates to: + /// **'Open track {trackName} by {artistName}'** + String a11yOpenTrackByArtist(String trackName, String artistName); + + /// Accessibility label for opening a generic item + /// + /// In en, this message translates to: + /// **'Open {itemType} {name}'** + String a11yOpenItem(String itemType, String name); + + /// Accessibility label for opening a grouped item with count + /// + /// In en, this message translates to: + /// **'Open {title}, {count} {count, plural, =1{item} other{items}}'** + String a11yOpenItemCount(String title, int count); + + /// Accessibility label for opening an album item with track count + /// + /// In en, this message translates to: + /// **'Open album {albumName} by {artistName}, {trackCount} tracks'** + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ); + + /// Accessibility label for a queue or list track item + /// + /// In en, this message translates to: + /// **'{trackName} by {artistName}'** + String a11yTrackByArtist(String trackName, String artistName); + + /// Accessibility label for selecting an album + /// + /// In en, this message translates to: + /// **'Select album {albumName}'** + String a11ySelectAlbum(String albumName); + + /// Accessibility label for opening an album + /// + /// In en, this message translates to: + /// **'Open album {albumName}'** + String a11yOpenAlbum(String albumName); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index db4980b8..fe074715 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -29,6 +29,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Unterstützt: Titel, Album, Playlist, Künstler-URLs'; @@ -3456,4 +3463,223 @@ class AppLocalizationsDe extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0315acb9..6beff188 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -29,6 +29,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeSubtitle => 'Paste a supported URL or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3424,4 +3431,223 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index b386974a..ffbe0335 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -29,6 +29,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get homeSubtitle => 'Paste a Spotify link or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3424,6 +3431,225 @@ class AppLocalizationsEs extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c7650c61..f8ebcf9d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -29,6 +29,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs'; @@ -3425,4 +3432,223 @@ class AppLocalizationsFr extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 3c17805c..0d22f888 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -29,6 +29,13 @@ class AppLocalizationsHi extends AppLocalizations { @override String get homeSubtitle => 'Paste a Spotify link or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3423,4 +3430,223 @@ class AppLocalizationsHi extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index df69b667..903b6169 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -30,6 +30,13 @@ class AppLocalizationsId extends AppLocalizations { String get homeSubtitle => 'Tempel URL yang didukung atau cari berdasarkan nama'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis'; @@ -3434,4 +3441,223 @@ class AppLocalizationsId extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3627a150..388d62a1 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -29,6 +29,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL'; @@ -3410,4 +3417,223 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 67500ccc..f334880e 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -29,6 +29,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs'; @@ -3403,4 +3410,223 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b095b648..4e933bc2 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -29,6 +29,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get homeSubtitle => 'Paste a Spotify link or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3423,4 +3430,223 @@ class AppLocalizationsNl extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 5ac4d5af..fa1cefb1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -29,6 +29,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get homeSubtitle => 'Paste a Spotify link or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3424,6 +3431,225 @@ class AppLocalizationsPt extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ee958fe2..c6165f0b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -29,6 +29,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Поддерживается: Трек, Альбом, Плейлист, URL исполнителя'; @@ -3483,4 +3490,223 @@ class AppLocalizationsRu extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index eda39c2b..f6437c4f 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -30,6 +30,13 @@ class AppLocalizationsTr extends AppLocalizations { String get homeSubtitle => 'Bir Spotify bağlantısı yapıştırın veya şarkı arayın'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Desteklenenler: Şarkı, Albüm, Çalma Listesi, Sanatçı bağlantıları'; @@ -3481,4 +3488,223 @@ class AppLocalizationsTr extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 84d89e4a..77f65213 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -29,6 +29,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeSubtitle => 'Paste a Spotify link or search by name'; + @override + String get homeEmptyTitle => 'Home is empty'; + + @override + String get homeEmptySubtitle => + 'Install your first extension to unlock search and browsing.'; + @override String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; @@ -3424,6 +3431,225 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifUpdateFailedBody => 'Could not download update. Try again later.'; + + @override + String get searchTracks => 'Tracks'; + + @override + String get homeSearchHintDefault => 'Paste supported URL or search...'; + + @override + String homeSearchHintProvider(String providerName) { + return 'Search with $providerName...'; + } + + @override + String get homeImportCsvTooltip => 'Import CSV'; + + @override + String get homeChangeSearchProviderTooltip => 'Change search provider'; + + @override + String get actionPaste => 'Paste'; + + @override + String get searchTracksHint => 'Search tracks...'; + + @override + String get searchTracksEmptyPrompt => 'Search for tracks'; + + @override + String get tutorialSearchHint => 'Paste or search...'; + + @override + String get tutorialDownloadCompletedSemantics => 'Download completed'; + + @override + String get tutorialDownloadInProgressSemantics => 'Download in progress'; + + @override + String get tutorialStartDownloadSemantics => 'Start download'; + + @override + String get optionsEmbedMetadata => 'Embed Metadata'; + + @override + String get optionsEmbedMetadataSubtitleOn => + 'Write metadata, cover art, and embedded lyrics to files'; + + @override + String get optionsEmbedMetadataSubtitleOff => + 'Disabled (advanced): skip all metadata embedding'; + + @override + String get optionsMaxQualityCoverSubtitleDisabled => + 'Disabled when metadata embedding is off'; + + @override + String downloadFilenameHintExample(Object artist, Object title) { + return '$artist - $title'; + } + + @override + String get trackCoverNoEmbeddedArt => 'No embedded album art found'; + + @override + String get trackCoverReplace => 'Replace Cover'; + + @override + String get trackCoverPick => 'Pick Cover'; + + @override + String get trackCoverClearSelected => 'Clear selected cover'; + + @override + String get trackCoverCurrent => 'Current cover'; + + @override + String get trackCoverSelected => 'Selected cover'; + + @override + String get trackCoverReplaceNotice => + 'The selected cover will replace the current embedded cover when you tap Save.'; + + @override + String get actionStop => 'Stop'; + + @override + String get queueFinalizingDownload => 'Finalizing download'; + + @override + String get queueDownloadedFileMissing => 'Downloaded file missing'; + + @override + String get queueDownloadCompleted => 'Download completed'; + + @override + String appearanceSelectAccentColor(String hex) { + return 'Select accent color $hex'; + } + + @override + String get logAutoScrollOn => 'Auto-scroll ON'; + + @override + String get logAutoScrollOff => 'Auto-scroll OFF'; + + @override + String get logCopyLogs => 'Copy logs'; + + @override + String get logClearSearch => 'Clear search'; + + @override + String get logIssueIspBlockingLabel => 'ISP BLOCKING DETECTED'; + + @override + String get logIssueIspBlockingDescription => + 'Your ISP may be blocking access to download services'; + + @override + String get logIssueIspBlockingSuggestion => + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8'; + + @override + String get logIssueRateLimitedLabel => 'RATE LIMITED'; + + @override + String get logIssueRateLimitedDescription => + 'Too many requests to the service'; + + @override + String get logIssueRateLimitedSuggestion => + 'Wait a few minutes before trying again'; + + @override + String get logIssueNetworkErrorLabel => 'NETWORK ERROR'; + + @override + String get logIssueNetworkErrorDescription => 'Connection issues detected'; + + @override + String get logIssueNetworkErrorSuggestion => 'Check your internet connection'; + + @override + String get logIssueTrackNotFoundLabel => 'TRACK NOT FOUND'; + + @override + String get logIssueTrackNotFoundDescription => + 'Some tracks could not be found on download services'; + + @override + String get logIssueTrackNotFoundSuggestion => + 'The track may not be available in lossless quality'; + + @override + String get clickableLookingUpArtist => 'Looking up artist...'; + + @override + String clickableInformationUnavailable(String type) { + return '$type information not available'; + } + + @override + String get extensionDetailsTags => 'Tags'; + + @override + String get extensionDetailsInformation => 'Information'; + + @override + String get extensionUtilityFunctions => 'Utility Functions'; + + @override + String get actionDismiss => 'Dismiss'; + + @override + String get setupChangeFolderTooltip => 'Change folder'; + + @override + String a11yOpenTrackByArtist(String trackName, String artistName) { + return 'Open track $trackName by $artistName'; + } + + @override + String a11yOpenItem(String itemType, String name) { + return 'Open $itemType $name'; + } + + @override + String a11yOpenItemCount(String title, int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'items', + one: 'item', + ); + return 'Open $title, $count $_temp0'; + } + + @override + String a11yOpenAlbumByArtistTrackCount( + String albumName, + String artistName, + int trackCount, + ) { + return 'Open album $albumName by $artistName, $trackCount tracks'; + } + + @override + String a11yTrackByArtist(String trackName, String artistName) { + return '$trackName by $artistName'; + } + + @override + String a11ySelectAlbum(String albumName) { + return 'Select album $albumName'; + } + + @override + String a11yOpenAlbum(String albumName) { + return 'Open album $albumName'; + } } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 53ea72bf..817d73cd 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -29,6 +29,14 @@ "@homeSubtitle": { "description": "Subtitle shown below search box" }, + "homeEmptyTitle": "Home is empty", + "@homeEmptyTitle": { + "description": "Title shown on home when no providers are available yet" + }, + "homeEmptySubtitle": "Install your first extension to unlock search and browsing.", + "@homeEmptySubtitle": { + "description": "Subtitle shown on home when no providers are available yet" + }, "homeSupports": "Supports: Track, Album, Playlist, Artist URLs", "@homeSupports": { "description": "Info text about supported URL types" @@ -4548,5 +4556,309 @@ "notifUpdateFailedBody": "Could not download update. Try again later.", "@notifUpdateFailedBody": { "description": "Notification body when app update download fails" + }, + "searchTracks": "Tracks", + "@searchTracks": { + "description": "Search filter label - tracks" + }, + "homeSearchHintDefault": "Paste supported URL or search...", + "@homeSearchHintDefault": { + "description": "Default placeholder for the main search field on Home" + }, + "homeSearchHintProvider": "Search with {providerName}...", + "@homeSearchHintProvider": { + "description": "Placeholder for the main search field when a provider is selected", + "placeholders": { + "providerName": { + "type": "String" + } + } + }, + "homeImportCsvTooltip": "Import CSV", + "@homeImportCsvTooltip": { + "description": "Tooltip for importing a CSV file into Home search" + }, + "homeChangeSearchProviderTooltip": "Change search provider", + "@homeChangeSearchProviderTooltip": { + "description": "Tooltip for the Home search provider picker" + }, + "actionPaste": "Paste", + "@actionPaste": { + "description": "Generic action - paste from clipboard" + }, + "searchTracksHint": "Search tracks...", + "@searchTracksHint": { + "description": "Placeholder for the search screen input" + }, + "searchTracksEmptyPrompt": "Search for tracks", + "@searchTracksEmptyPrompt": { + "description": "Empty-state prompt on the search screen" + }, + "tutorialSearchHint": "Paste or search...", + "@tutorialSearchHint": { + "description": "Placeholder shown in the tutorial search demo" + }, + "tutorialDownloadCompletedSemantics": "Download completed", + "@tutorialDownloadCompletedSemantics": { + "description": "Accessibility label for completed download state in tutorial demo" + }, + "tutorialDownloadInProgressSemantics": "Download in progress", + "@tutorialDownloadInProgressSemantics": { + "description": "Accessibility label for active download state in tutorial demo" + }, + "tutorialStartDownloadSemantics": "Start download", + "@tutorialStartDownloadSemantics": { + "description": "Accessibility label for idle download button in tutorial demo" + }, + "optionsEmbedMetadata": "Embed Metadata", + "@optionsEmbedMetadata": { + "description": "Settings toggle title for writing metadata into downloaded files" + }, + "optionsEmbedMetadataSubtitleOn": "Write metadata, cover art, and embedded lyrics to files", + "@optionsEmbedMetadataSubtitleOn": { + "description": "Subtitle when metadata embedding is enabled" + }, + "optionsEmbedMetadataSubtitleOff": "Disabled (advanced): skip all metadata embedding", + "@optionsEmbedMetadataSubtitleOff": { + "description": "Subtitle when metadata embedding is disabled" + }, + "optionsMaxQualityCoverSubtitleDisabled": "Disabled when metadata embedding is off", + "@optionsMaxQualityCoverSubtitleDisabled": { + "description": "Subtitle for max quality cover when metadata embedding is disabled" + }, + "downloadFilenameHintExample": "{artist} - {title}", + "@downloadFilenameHintExample": { + "description": "Example placeholder for the download filename format input" + }, + "trackCoverNoEmbeddedArt": "No embedded album art found", + "@trackCoverNoEmbeddedArt": { + "description": "Message shown when a track file has no embedded cover art" + }, + "trackCoverReplace": "Replace Cover", + "@trackCoverReplace": { + "description": "Button label for replacing selected cover art" + }, + "trackCoverPick": "Pick Cover", + "@trackCoverPick": { + "description": "Button label for selecting cover art" + }, + "trackCoverClearSelected": "Clear selected cover", + "@trackCoverClearSelected": { + "description": "Tooltip for clearing the newly selected cover art" + }, + "trackCoverCurrent": "Current cover", + "@trackCoverCurrent": { + "description": "Label for the currently embedded cover preview" + }, + "trackCoverSelected": "Selected cover", + "@trackCoverSelected": { + "description": "Label for the newly selected cover preview" + }, + "trackCoverReplaceNotice": "The selected cover will replace the current embedded cover when you tap Save.", + "@trackCoverReplaceNotice": { + "description": "Notice shown when a new cover has been selected but not saved yet" + }, + "actionStop": "Stop", + "@actionStop": { + "description": "Generic action - stop" + }, + "queueFinalizingDownload": "Finalizing download", + "@queueFinalizingDownload": { + "description": "Accessibility label for a queue item that is finalizing" + }, + "queueDownloadedFileMissing": "Downloaded file missing", + "@queueDownloadedFileMissing": { + "description": "Accessibility label when a downloaded file is missing from disk" + }, + "queueDownloadCompleted": "Download completed", + "@queueDownloadCompleted": { + "description": "Accessibility label for completed download state in queue" + }, + "appearanceSelectAccentColor": "Select accent color {hex}", + "@appearanceSelectAccentColor": { + "description": "Accessibility label for picking an accent color", + "placeholders": { + "hex": { + "type": "String" + } + } + }, + "logAutoScrollOn": "Auto-scroll ON", + "@logAutoScrollOn": { + "description": "Tooltip when auto-scroll is enabled on the log screen" + }, + "logAutoScrollOff": "Auto-scroll OFF", + "@logAutoScrollOff": { + "description": "Tooltip when auto-scroll is disabled on the log screen" + }, + "logCopyLogs": "Copy logs", + "@logCopyLogs": { + "description": "Tooltip for copying logs" + }, + "logClearSearch": "Clear search", + "@logClearSearch": { + "description": "Tooltip for clearing the log search field" + }, + "logIssueIspBlockingLabel": "ISP BLOCKING DETECTED", + "@logIssueIspBlockingLabel": { + "description": "Diagnostic badge label when ISP blocking is detected" + }, + "logIssueIspBlockingDescription": "Your ISP may be blocking access to download services", + "@logIssueIspBlockingDescription": { + "description": "Diagnostic badge description for ISP blocking" + }, + "logIssueIspBlockingSuggestion": "Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8", + "@logIssueIspBlockingSuggestion": { + "description": "Diagnostic badge suggestion for ISP blocking" + }, + "logIssueRateLimitedLabel": "RATE LIMITED", + "@logIssueRateLimitedLabel": { + "description": "Diagnostic badge label when the service rate limits requests" + }, + "logIssueRateLimitedDescription": "Too many requests to the service", + "@logIssueRateLimitedDescription": { + "description": "Diagnostic badge description for rate limiting" + }, + "logIssueRateLimitedSuggestion": "Wait a few minutes before trying again", + "@logIssueRateLimitedSuggestion": { + "description": "Diagnostic badge suggestion for rate limiting" + }, + "logIssueNetworkErrorLabel": "NETWORK ERROR", + "@logIssueNetworkErrorLabel": { + "description": "Diagnostic badge label for generic network errors" + }, + "logIssueNetworkErrorDescription": "Connection issues detected", + "@logIssueNetworkErrorDescription": { + "description": "Diagnostic badge description for generic network errors" + }, + "logIssueNetworkErrorSuggestion": "Check your internet connection", + "@logIssueNetworkErrorSuggestion": { + "description": "Diagnostic badge suggestion for generic network errors" + }, + "logIssueTrackNotFoundLabel": "TRACK NOT FOUND", + "@logIssueTrackNotFoundLabel": { + "description": "Diagnostic badge label when a track is unavailable" + }, + "logIssueTrackNotFoundDescription": "Some tracks could not be found on download services", + "@logIssueTrackNotFoundDescription": { + "description": "Diagnostic badge description when a track is unavailable" + }, + "logIssueTrackNotFoundSuggestion": "The track may not be available in lossless quality", + "@logIssueTrackNotFoundSuggestion": { + "description": "Diagnostic badge suggestion when a track is unavailable" + }, + "clickableLookingUpArtist": "Looking up artist...", + "@clickableLookingUpArtist": { + "description": "Snackbar shown while clickable artist metadata is being resolved" + }, + "clickableInformationUnavailable": "{type} information not available", + "@clickableInformationUnavailable": { + "description": "Snackbar shown when clickable metadata cannot open a destination", + "placeholders": { + "type": { + "type": "String" + } + } + }, + "extensionDetailsTags": "Tags", + "@extensionDetailsTags": { + "description": "Section title for extension tags" + }, + "extensionDetailsInformation": "Information", + "@extensionDetailsInformation": { + "description": "Section title for extension metadata information" + }, + "extensionUtilityFunctions": "Utility Functions", + "@extensionUtilityFunctions": { + "description": "Capability label for utility-only extensions" + }, + "actionDismiss": "Dismiss", + "@actionDismiss": { + "description": "Generic action - dismiss" + }, + "setupChangeFolderTooltip": "Change folder", + "@setupChangeFolderTooltip": { + "description": "Tooltip for editing the selected download folder" + }, + "a11yOpenTrackByArtist": "Open track {trackName} by {artistName}", + "@a11yOpenTrackByArtist": { + "description": "Accessibility label for opening a track item", + "placeholders": { + "trackName": { + "type": "String" + }, + "artistName": { + "type": "String" + } + } + }, + "a11yOpenItem": "Open {itemType} {name}", + "@a11yOpenItem": { + "description": "Accessibility label for opening a generic item", + "placeholders": { + "itemType": { + "type": "String" + }, + "name": { + "type": "String" + } + } + }, + "a11yOpenItemCount": "Open {title}, {count} {count, plural, =1{item} other{items}}", + "@a11yOpenItemCount": { + "description": "Accessibility label for opening a grouped item with count", + "placeholders": { + "title": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "a11yOpenAlbumByArtistTrackCount": "Open album {albumName} by {artistName}, {trackCount} tracks", + "@a11yOpenAlbumByArtistTrackCount": { + "description": "Accessibility label for opening an album item with track count", + "placeholders": { + "albumName": { + "type": "String" + }, + "artistName": { + "type": "String" + }, + "trackCount": { + "type": "int" + } + } + }, + "a11yTrackByArtist": "{trackName} by {artistName}", + "@a11yTrackByArtist": { + "description": "Accessibility label for a queue or list track item", + "placeholders": { + "trackName": { + "type": "String" + }, + "artistName": { + "type": "String" + } + } + }, + "a11ySelectAlbum": "Select album {albumName}", + "@a11ySelectAlbum": { + "description": "Accessibility label for selecting an album", + "placeholders": { + "albumName": { + "type": "String" + } + } + }, + "a11yOpenAlbum": "Open album {albumName}", + "@a11yOpenAlbum": { + "description": "Accessibility label for opening an album", + "placeholders": { + "albumName": { + "type": "String" + } + } } } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 18c77f73..c8ba8a54 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -85,7 +85,7 @@ class AppSettings { lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') const AppSettings({ - this.defaultService = 'tidal', + this.defaultService = '', this.audioQuality = 'LOSSLESS', this.filenameFormat = '{title} - {artist}', this.downloadDirectory = '', diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 10e40855..abcd5c3d 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -7,7 +7,7 @@ part of 'settings.dart'; // ************************************************************************** AppSettings _$AppSettingsFromJson(Map json) => AppSettings( - defaultService: json['defaultService'] as String? ?? 'tidal', + defaultService: json['defaultService'] as String? ?? '', audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS', filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}', downloadDirectory: json['downloadDirectory'] as String? ?? '', diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d4748d9e..d795fa9b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1455,6 +1455,10 @@ class DownloadQueueNotifier extends Notifier { final decoded = jsonDecode(itemJson); if (decoded is! Map) continue; var item = DownloadItem.fromJson(Map.from(decoded)); + final normalizedService = _normalizeQueuedService(item.service); + if (normalizedService != item.service) { + item = item.copyWith(service: normalizedService); + } if (item.status == DownloadStatus.downloading) { item = item.copyWith(status: DownloadStatus.queued, progress: 0); } @@ -2395,7 +2399,8 @@ class DownloadQueueNotifier extends Notifier { if (extensionPreferred != null) { return extensionPreferred; } - if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { + if (_usesBuiltInCompatibleDownloadProvider(service, 'tidal') && + quality == 'HIGH') { return '.m4a'; } final q = quality.toLowerCase(); @@ -2405,6 +2410,49 @@ class DownloadQueueNotifier extends Notifier { return '.flac'; } + bool _usesBuiltInCompatibleDownloadProvider( + String service, + String builtInProviderId, + ) { + return ref + .read(extensionProvider.notifier) + .downloadProviderMatchesBuiltIn(service, builtInProviderId); + } + + String _normalizeQueuedService(String service) { + final normalized = service.trim(); + if (normalized.isEmpty) { + return normalized; + } + + final replacement = ref + .read(extensionProvider.notifier) + .replacedBuiltInDownloadProviderFor(normalized); + if (replacement != null && replacement.isNotEmpty) { + return replacement; + } + + return normalized; + } + + bool _hasActiveDownloadProvider(String service) { + final normalized = service.trim(); + if (normalized.isEmpty) { + return false; + } + if (isBuiltInDownloadProvider(normalized)) { + return true; + } + + final extensionState = ref.read(extensionProvider); + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.id.toLowerCase() == normalized.toLowerCase(), + ); + } + String _mimeTypeForExt(String ext) { switch (ext.toLowerCase()) { case '.m4a': @@ -2837,7 +2885,7 @@ class DownloadQueueNotifier extends Notifier { final item = DownloadItem( id: id, track: track, - service: service, + service: _normalizeQueuedService(service), createdAt: DateTime.now(), qualityOverride: qualityOverride, playlistName: playlistName, @@ -2869,7 +2917,7 @@ class DownloadQueueNotifier extends Notifier { return DownloadItem( id: id, track: track, - service: service, + service: _normalizeQueuedService(service), createdAt: DateTime.now(), qualityOverride: qualityOverride, playlistName: playlistName, @@ -4388,6 +4436,31 @@ class DownloadQueueNotifier extends Notifier { } Future _downloadSingleItem(DownloadItem item) async { + final normalizedService = _normalizeQueuedService(item.service); + if (normalizedService != item.service) { + item = item.copyWith(service: normalizedService); + state = state.copyWith( + items: [ + for (final existing in state.items) + if (existing.id == item.id) item else existing, + ], + currentDownload: state.currentDownload?.id == item.id + ? item + : state.currentDownload, + ); + _saveQueueToStorage(); + } + + if (!_hasActiveDownloadProvider(item.service)) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Download provider is no longer available', + errorType: DownloadErrorType.notFound, + ); + return; + } + _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); var pausedDuringThisRun = false; @@ -4748,7 +4821,7 @@ class DownloadQueueNotifier extends Notifier { } if (trackToDownload.id.startsWith('tidal:')) { payloadTidalId = trackToDownload.id.substring(6); - if (item.service == 'tidal') { + if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) { payloadSpotifyId = ''; } } @@ -5051,7 +5124,7 @@ class DownloadQueueNotifier extends Notifier { !wasExisting && isContentUriPath && effectiveSafMode && - actualService == 'tidal' && + _usesBuiltInCompatibleDownloadProvider(actualService, 'tidal') && filePath.endsWith('.flac') && (mimeType == null || mimeType.contains('flac')); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 32d19547..12ef6add 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -211,6 +211,20 @@ class Extension { bool get hasPostProcessing => postProcessing?.enabled ?? false; bool get hasHomeFeed => capabilities['homeFeed'] == true; bool get hasBrowseCategories => capabilities['browseCategories'] == true; + List get replacesBuiltInProviders { + final value = capabilities['replacesBuiltInProviders']; + if (value is! List) return const []; + + final normalized = []; + for (final item in value) { + if (item is! String) continue; + final trimmed = item.trim().toLowerCase(); + if (trimmed.isEmpty || normalized.contains(trimmed)) continue; + normalized.add(trimmed); + } + return normalized; + } + String? get preferredDownloadOutputExtension { final value = capabilities['downloadOutputExtension']; if (value is! String) return null; @@ -743,6 +757,8 @@ class ExtensionNotifier extends Notifier { final extensions = list.map((e) => Extension.fromJson(e)).toList(); state = state.copyWith(extensions: extensions); await _reconcileDownloadProviderPriority(); + await _reconcileDefaultDownloadService(); + _reconcileSearchProvider(); _log.d('Loaded ${extensions.length} extensions'); for (final ext in extensions) { @@ -849,6 +865,8 @@ class ExtensionNotifier extends Notifier { state = state.copyWith(extensions: extensions); await _reconcileDownloadProviderPriority(); + await _reconcileDefaultDownloadService(); + _reconcileSearchProvider(); if (!enabled && ext != null) { final settings = ref.read(settingsProvider); @@ -861,16 +879,16 @@ class ExtensionNotifier extends Notifier { } if (ext.hasDownloadProvider && settings.defaultService == extensionId) { - final availableProviders = getAllDownloadProviders(); - if (availableProviders.isNotEmpty) { - final fallbackService = availableProviders.first; - ref - .read(settingsProvider.notifier) - .setDefaultService(fallbackService); - _log.d( - 'Reset default service to $fallbackService because extension $extensionId was disabled', - ); - } + final fallbackService = + _firstEnabledExtensionDownloadProviderId() ?? ''; + ref + .read(settingsProvider.notifier) + .setDefaultService(fallbackService); + _log.d( + fallbackService.isEmpty + ? 'Cleared default service because extension $extensionId was disabled' + : 'Reset default service to $fallbackService because extension $extensionId was disabled', + ); } } } catch (e) { @@ -896,6 +914,142 @@ class ExtensionNotifier extends Notifier { _log.d('Reconciled provider priority after extension update: $sanitized'); } + String? _firstEnabledExtensionDownloadProviderId() { + return state.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .map((ext) => ext.id) + .firstOrNull; + } + + String? replacedBuiltInDownloadProviderFor(String providerId) { + final normalized = providerId.trim().toLowerCase(); + if (normalized.isEmpty) return null; + + return state.extensions + .where( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.replacesBuiltInProviders.contains(normalized), + ) + .map((ext) => ext.id) + .firstOrNull; + } + + String? replacedBuiltInSearchProviderFor(String providerId) { + final normalized = providerId.trim().toLowerCase(); + if (normalized.isEmpty) return null; + + return state.extensions + .where( + (ext) => + ext.enabled && + ext.hasCustomSearch && + ext.replacesBuiltInProviders.contains(normalized), + ) + .map((ext) => ext.id) + .firstOrNull; + } + + bool downloadProviderMatchesBuiltIn( + String providerId, + String builtInProviderId, + ) { + final normalizedProvider = providerId.trim().toLowerCase(); + final normalizedBuiltIn = builtInProviderId.trim().toLowerCase(); + if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false; + if (normalizedProvider == normalizedBuiltIn) return true; + + final extension = state.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .where((ext) => ext.id.toLowerCase() == normalizedProvider) + .firstOrNull; + return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ?? + false; + } + + Future _reconcileDefaultDownloadService() async { + final settings = ref.read(settingsProvider); + final preferredExtensionId = _firstEnabledExtensionDownloadProviderId(); + final currentService = settings.defaultService.trim(); + + if (currentService.isEmpty) { + if (preferredExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setDefaultService(preferredExtensionId); + _log.d( + 'Adopted first enabled download extension as default service: $preferredExtensionId', + ); + } + return; + } + + final replacementExtensionId = replacedBuiltInDownloadProviderFor( + currentService, + ); + if (replacementExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setDefaultService(replacementExtensionId); + _log.d( + 'Migrated retired built-in service $currentService to $replacementExtensionId', + ); + return; + } + + final currentExtension = state.extensions + .where((ext) => ext.id == currentService) + .firstOrNull; + final isMissingOrInvalidExtension = + currentExtension == null || + !currentExtension.enabled || + !currentExtension.hasDownloadProvider; + if (!isBuiltInDownloadProvider(currentService) && + isMissingOrInvalidExtension) { + final fallbackService = preferredExtensionId ?? ''; + ref.read(settingsProvider.notifier).setDefaultService(fallbackService); + _log.d( + fallbackService.isEmpty + ? 'Cleared default service because $currentService is no longer available' + : 'Reset default service to $fallbackService because $currentService is no longer available', + ); + } + } + + void _reconcileSearchProvider() { + final settings = ref.read(settingsProvider); + final currentSearchProvider = settings.searchProvider?.trim(); + if (currentSearchProvider == null || currentSearchProvider.isEmpty) { + return; + } + + final replacementExtensionId = replacedBuiltInSearchProviderFor( + currentSearchProvider, + ); + if (replacementExtensionId != null) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(replacementExtensionId); + _log.d( + 'Migrated retired built-in search provider $currentSearchProvider to $replacementExtensionId', + ); + return; + } + + final hasMatchingExtension = state.extensions.any( + (ext) => + ext.enabled && ext.hasCustomSearch && ext.id == currentSearchProvider, + ); + if (!isBuiltInSearchProvider(currentSearchProvider) && + !hasMatchingExtension) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + _log.d( + 'Cleared stale search provider because $currentSearchProvider is no longer available', + ); + } + } + Future ensureSpotifyWebExtensionReady({ bool setAsSearchProvider = true, }) async { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 4e2ea6ca..fc254b4e 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -12,13 +12,18 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 10; +const _currentMigrationVersion = 11; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); - static const Set _searchTabValues = {'all', 'track', 'artist', 'album'}; + static const Set _searchTabValues = { + 'all', + 'track', + 'artist', + 'album', + }; final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @@ -137,10 +142,11 @@ class SettingsNotifier extends Notifier { ); } state = state.copyWith(lastSeenVersion: AppInfo.version); - // Migration 7/10: retired built-in services reset back to Tidal + // Migration 7/11: retired built-in services no longer fall back to a + // preinstalled provider. if (state.defaultService == 'youtube' || state.defaultService == 'deezer') { - state = state.copyWith(defaultService: 'tidal'); + state = state.copyWith(defaultService: ''); } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4391f127..9db86fb6 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1689,8 +1689,8 @@ class _ArtistScreenState extends ConsumerState { button: true, selected: _isSelectionMode && isSelected, label: _isSelectionMode - ? 'Select album ${album.name}' - : 'Open album ${album.name}', + ? context.l10n.a11ySelectAlbum(album.name) + : context.l10n.a11yOpenAlbum(album.name), child: GestureDetector( onTap: () { if (_isSelectionMode) { diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 1e4d65c4..fc51252a 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -866,7 +866,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - tooltip: 'Play track', + tooltip: context.l10n.tooltipPlay, onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 1e697ddc..f5c6f69b 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -431,6 +431,7 @@ class _HomeTabState extends ConsumerState } List _resolveSearchFilters( + BuildContext context, String? currentSearchProvider, List extensions, ) { @@ -453,11 +454,27 @@ class _HomeTabState extends ConsumerState } } - return const [ - SearchFilter(id: 'track', label: 'Tracks', icon: 'music'), - SearchFilter(id: 'artist', label: 'Artists', icon: 'artist'), - SearchFilter(id: 'album', label: 'Albums', icon: 'album'), - SearchFilter(id: 'playlist', label: 'Playlists', icon: 'playlist'), + return [ + SearchFilter( + id: 'track', + label: context.l10n.searchTracks, + icon: 'music', + ), + SearchFilter( + id: 'artist', + label: context.l10n.searchArtists, + icon: 'artist', + ), + SearchFilter( + id: 'album', + label: context.l10n.searchAlbums, + icon: 'album', + ), + SearchFilter( + id: 'playlist', + label: context.l10n.searchPlaylists, + icon: 'playlist', + ), ]; } @@ -1249,9 +1266,13 @@ class _HomeTabState extends ConsumerState final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); + final explicitSearchProvider = ref.watch( + settingsProvider.select((s) => s.searchProvider), + ); final defaultSearchTab = ref.watch( settingsProvider.select((s) => s.defaultSearchTab), ); + final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); final hasExploreContent = ref.watch( exploreProvider.select((s) => s.sections.isNotEmpty), @@ -1289,6 +1310,9 @@ class _HomeTabState extends ConsumerState recentModeRequested && (!hasSearchInput || hasShortSearchInput || !hasActualResults) && !isLoading; + final hasSearchProvider = + (_resolveSearchProvider(explicitSearchProvider, extensions) ?? '') + .isNotEmpty; final hasResults = hasSearchInput || hasActualResults || isLoading || showRecentAccess; final showExplore = @@ -1298,6 +1322,12 @@ class _HomeTabState extends ConsumerState !homeFeedDisabled && (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; + final showEmptyHomeState = + !hasSearchProvider && + !hasHomeFeedExtension && + !hasExploreContent && + !hasResults && + historyItems.isEmpty; ref.listen(settingsProvider.select((s) => s.defaultSearchTab), ( previous, @@ -1413,13 +1443,17 @@ class _HomeTabState extends ConsumerState ), const SizedBox(height: 16), Text( - 'SpotiFLAC', + showEmptyHomeState + ? context.l10n.homeEmptyTitle + : 'SpotiFLAC Mobile', style: Theme.of(context).textTheme.headlineSmall ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( - context.l10n.homeSubtitle, + showEmptyHomeState + ? context.l10n.homeEmptySubtitle + : context.l10n.homeSubtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( @@ -1431,17 +1465,18 @@ class _HomeTabState extends ConsumerState ), ), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - (hasResults || showExplore) ? 8 : 32, - 16, - (hasResults || showExplore) ? 8 : 16, + if (hasSearchProvider) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + (hasResults || showExplore) ? 8 : 32, + 16, + (hasResults || showExplore) ? 8 : 16, + ), + child: _buildSearchBar(colorScheme), ), - child: _buildSearchBar(colorScheme), ), - ), if (hasActualResults && !showRecentAccess) Consumer( @@ -1456,6 +1491,7 @@ class _HomeTabState extends ConsumerState trackProvider.select((s) => s.selectedSearchFilter), ); final searchFilters = _resolveSearchFilters( + context, currentSearchProvider, extensions, ); @@ -1493,7 +1529,11 @@ class _HomeTabState extends ConsumerState child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: (hasResults || showRecentAccess || showExplore) + child: + (hasResults || + showRecentAccess || + showExplore || + showEmptyHomeState) ? const SizedBox.shrink() : Column( children: [ @@ -1666,7 +1706,10 @@ class _HomeTabState extends ConsumerState key: ValueKey(item.id), child: Semantics( button: true, - label: 'Open track ${item.trackName} by ${item.artistName}', + label: context.l10n.a11yOpenTrackByArtist( + item.trackName, + item.artistName, + ), child: GestureDetector( onTap: () => _navigateToMetadataScreen( item, @@ -1810,7 +1853,7 @@ class _HomeTabState extends ConsumerState return Semantics( button: true, - label: 'Open ${item.type} ${item.name}', + label: context.l10n.a11yOpenItem(item.type, item.name), child: GestureDetector( onTap: () => _navigateToExploreItem(item), child: SizedBox( @@ -2294,7 +2337,7 @@ class _HomeTabState extends ConsumerState ), ), IconButton( - tooltip: 'Dismiss', + tooltip: context.l10n.actionDismiss, icon: Icon( Icons.close, size: 20, @@ -3341,13 +3384,13 @@ class _HomeTabState extends ConsumerState ); if (!extState.isInitialized) { - return 'Paste supported URL or search...'; + return context.l10n.homeSearchHintDefault; } if (searchProvider != null && searchProvider.isNotEmpty) { final builtIn = builtInProviderSpecForId(searchProvider); if (builtIn != null && builtIn.supportsSearch) { - return 'Search with ${builtIn.displayName}...'; + return context.l10n.homeSearchHintProvider(builtIn.displayName); } final ext = extState.extensions @@ -3357,10 +3400,10 @@ class _HomeTabState extends ConsumerState if (ext.searchBehavior?.placeholder != null) { return ext.searchBehavior!.placeholder!; } - return 'Search with ${ext.displayName}...'; + return context.l10n.homeSearchHintProvider(ext.displayName); } } - return 'Paste supported URL or search...'; + return context.l10n.homeSearchHintDefault; } Widget _buildSearchFilterBar( @@ -3481,7 +3524,7 @@ class _HomeTabState extends ConsumerState IconButton( icon: const Icon(Icons.clear), onPressed: _clearAndRefresh, - tooltip: 'Clear', + tooltip: context.l10n.dialogClear, ) else ...[ IconButton( @@ -3489,12 +3532,12 @@ class _HomeTabState extends ConsumerState onPressed: _isCsvImporting ? null : () => _importCsv(context, ref), - tooltip: 'Import CSV', + tooltip: context.l10n.homeImportCsvTooltip, ), IconButton( icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard, - tooltip: 'Paste', + tooltip: context.l10n.actionPaste, ), ], ], @@ -3640,7 +3683,7 @@ class _SearchProviderDropdown extends ConsumerWidget { ), ], ), - tooltip: 'Change search provider', + tooltip: context.l10n.homeChangeSearchProviderTooltip, offset: const Offset(0, 40), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: (String providerId) { diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 6cdd41dd..7af6654a 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -737,7 +737,7 @@ class _LocalAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( - tooltip: 'Play track', + tooltip: context.l10n.tooltipPlay, onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index d62772d1..7286af9c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -3537,7 +3537,7 @@ class _QueueTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( - tooltip: 'Clear', + tooltip: context.l10n.dialogClear, icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -3954,7 +3954,7 @@ class _QueueTabState extends ConsumerState { return Semantics( button: true, - label: 'Open $title, $count ${count == 1 ? 'item' : 'items'}', + label: context.l10n.a11yOpenItemCount(title, count), child: GestureDetector( onTap: onTap, onLongPress: onLongPress, @@ -4909,7 +4909,11 @@ class _QueueTabState extends ConsumerState { }) { return Semantics( button: true, - label: 'Open album $albumName by $artistName, $trackCount tracks', + label: context.l10n.a11yOpenAlbumByArtistTrackCount( + albumName, + artistName, + trackCount, + ), child: GestureDetector( onTap: onTap, child: Column( @@ -6444,7 +6448,7 @@ class _QueueTabState extends ConsumerState { onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.close, color: colorScheme.error), - tooltip: 'Cancel', + tooltip: context.l10n.dialogCancel, style: IconButton.styleFrom( backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), ), @@ -6454,14 +6458,14 @@ class _QueueTabState extends ConsumerState { onPressed: () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id), icon: Icon(Icons.stop, color: colorScheme.error), - tooltip: 'Stop', + tooltip: context.l10n.actionStop, style: IconButton.styleFrom( backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.3), ), ); case DownloadStatus.finalizing: return Semantics( - label: 'Finalizing download', + label: context.l10n.queueFinalizingDownload, child: SizedBox( width: 40, height: 40, @@ -6500,7 +6504,7 @@ class _QueueTabState extends ConsumerState { coverUrl: item.track.coverUrl ?? '', ), icon: Icon(Icons.play_arrow, color: colorScheme.primary), - tooltip: 'Play', + tooltip: context.l10n.tooltipPlay, style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( alpha: 0.3, @@ -6509,7 +6513,7 @@ class _QueueTabState extends ConsumerState { ) else Semantics( - label: 'Downloaded file missing', + label: context.l10n.queueDownloadedFileMissing, child: ExcludeSemantics( child: Icon( Icons.error_outline, @@ -6520,7 +6524,7 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 4), Semantics( - label: 'Download completed', + label: context.l10n.queueDownloadCompleted, child: ExcludeSemantics( child: Container( padding: const EdgeInsets.all(8), @@ -6549,7 +6553,7 @@ class _QueueTabState extends ConsumerState { onPressed: () => ref.read(downloadQueueProvider.notifier).retryItem(item.id), icon: Icon(Icons.refresh, color: colorScheme.primary), - tooltip: 'Retry', + tooltip: context.l10n.dialogRetry, style: IconButton.styleFrom( backgroundColor: colorScheme.primaryContainer.withValues( alpha: 0.3, @@ -6566,7 +6570,7 @@ class _QueueTabState extends ConsumerState { ? colorScheme.error : colorScheme.onSurfaceVariant, ), - tooltip: 'Remove', + tooltip: context.l10n.dialogRemove, style: item.status == DownloadStatus.failed ? IconButton.styleFrom( backgroundColor: colorScheme.errorContainer.withValues( @@ -6743,7 +6747,10 @@ class _QueueTabState extends ConsumerState { : colorScheme.onSecondaryContainer; return Semantics( - label: '${item.trackName} by ${item.artistName}', + label: context.l10n.a11yTrackByArtist( + item.trackName, + item.artistName, + ), selected: isSelected, child: Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index e8daba8f..e6aa3276 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -142,7 +142,7 @@ class _RepoTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( - tooltip: 'Clear', + tooltip: context.l10n.dialogClear, icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 0268e149..7ff7fd7b 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -70,7 +70,7 @@ class _SearchScreenState extends ConsumerState { controller: _searchController, style: TextStyle(color: colorScheme.onSurface), decoration: InputDecoration( - hintText: 'Search tracks...', + hintText: context.l10n.searchTracksHint, hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), border: InputBorder.none, enabledBorder: InputBorder.none, @@ -124,7 +124,7 @@ class _SearchScreenState extends ConsumerState { Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant), const SizedBox(height: 16), Text( - 'Search for tracks', + context.l10n.searchTracksEmptyPrompt, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -194,7 +194,7 @@ class _SearchScreenState extends ConsumerState { children: [ IconButton( icon: const Icon(Icons.download_rounded), - tooltip: 'Download', + tooltip: context.l10n.dialogDownload, onPressed: () => _downloadTrack(track), ), ], diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 75d93e6e..2ec426a6 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -358,7 +358,7 @@ class _ColorPalettePicker extends StatelessWidget { child: Semantics( button: true, selected: isSelected, - label: 'Select accent color $colorHex', + label: context.l10n.appearanceSelectAccentColor(colorHex), child: GestureDetector( onTap: () => onColorSelected(color), child: _ColorPaletteItem(color: color, isSelected: isSelected), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 8e6d3901..21265387 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -297,11 +297,34 @@ class _DownloadSettingsPageState extends ConsumerState { @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); + final extensionState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); + final extensionDownloadProviders = extensionState.extensions + .where( + (extension) => extension.enabled && extension.hasDownloadProvider, + ) + .toList(growable: false); + final extensionNotifier = ref.read(extensionProvider.notifier); + final hasDownloadProviders = + builtInDownloadProviderSpecs.isNotEmpty || + extensionDownloadProviders.isNotEmpty; final isBuiltInService = isBuiltInDownloadProvider(settings.defaultService); - final isTidalService = settings.defaultService == 'tidal'; + final replacedBuiltInServiceId = builtInDownloadProviderSpecs + .map((provider) => provider.id) + .where( + (providerId) => extensionNotifier.downloadProviderMatchesBuiltIn( + settings.defaultService, + providerId, + ), + ) + .firstOrNull; + final effectiveBuiltInServiceId = isBuiltInService + ? settings.defaultService + : replacedBuiltInServiceId; + final isBuiltInCompatibleService = effectiveBuiltInServiceId != null; + final isTidalService = effectiveBuiltInServiceId == 'tidal'; return PopScope( canPop: true, @@ -375,17 +398,19 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsSwitchItem( icon: Icons.tune, title: context.l10n.downloadAskBeforeDownload, - subtitle: isBuiltInService + subtitle: !hasDownloadProviders + ? context.l10n.extensionsNoDownloadProvider + : isBuiltInCompatibleService ? context.l10n.downloadAskQualitySubtitle : context.l10n.downloadSelectServiceToEnable, value: settings.askQualityBeforeDownload, - enabled: isBuiltInService, + enabled: hasDownloadProviders && isBuiltInCompatibleService, onChanged: (value) => ref .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), if (!settings.askQualityBeforeDownload && - isBuiltInService) ...[ + isBuiltInCompatibleService) ...[ _QualityOption( title: context.l10n.qualityFlacLossless, subtitle: context.l10n.qualityFlacLosslessSubtitle, @@ -441,7 +466,13 @@ class _DownloadSettingsPageState extends ConsumerState { showDivider: false, ), ], - if (!isBuiltInService) ...[ + if (!hasDownloadProviders) ...[ + _InlineInfoMessage( + icon: Icons.extension_outlined, + text: context.l10n.extensionsNoDownloadProvider, + secondaryText: context.l10n.storeAddRepoDescription, + ), + ] else if (!isBuiltInCompatibleService) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Row( @@ -1076,7 +1107,10 @@ class _DownloadSettingsPageState extends ConsumerState { TextField( controller: controller, decoration: InputDecoration( - hintText: '{artist} - {title}', + hintText: context.l10n.downloadFilenameHintExample( + '{artist}', + '{title}', + ), filled: true, fillColor: colorScheme.surfaceContainerHighest .withValues(alpha: 0.3), @@ -2070,34 +2104,42 @@ class _ServiceSelector extends ConsumerWidget { padding: const EdgeInsets.all(12), child: Column( children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final provider in builtInProviders) - _ServiceChip( - icon: resolveProviderIcon(provider.id), - label: provider.displayName, - isSelected: effectiveService == provider.id, - onTap: () => onChanged(provider.id), - ), - ], - ), - if (extensionProviders.isNotEmpty) ...[ - const SizedBox(height: 8), + if (builtInProviders.isEmpty && extensionProviders.isEmpty) + _InlineInfoMessage( + icon: Icons.extension_outlined, + text: context.l10n.extensionsNoDownloadProvider, + secondaryText: context.l10n.storeAddRepoDescription, + ) + else ...[ Wrap( spacing: 8, runSpacing: 8, children: [ - for (final extension in extensionProviders) + for (final provider in builtInProviders) _ServiceChip( - icon: Icons.extension, - label: extension.displayName, - isSelected: effectiveService == extension.id, - onTap: () => onChanged(extension.id), + icon: resolveProviderIcon(provider.id), + label: provider.displayName, + isSelected: effectiveService == provider.id, + onTap: () => onChanged(provider.id), ), ], ), + if (extensionProviders.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final extension in extensionProviders) + _ServiceChip( + icon: Icons.extension, + label: extension.displayName, + isSelected: effectiveService == extension.id, + onTap: () => onChanged(extension.id), + ), + ], + ), + ], ], ], ), @@ -2105,6 +2147,63 @@ class _ServiceSelector extends ConsumerWidget { } } +class _InlineInfoMessage extends StatelessWidget { + final IconData icon; + final String text; + final String? secondaryText; + + const _InlineInfoMessage({ + required this.icon, + required this.text, + this.secondaryText, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + if (secondaryText != null && secondaryText!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + secondaryText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + class _ServiceChip extends StatelessWidget { final IconData icon; final String label; diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index d1537ae7..8377ed8f 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -160,12 +160,14 @@ class _LogScreenState extends State { ? Icons.vertical_align_bottom : Icons.vertical_align_center, ), - tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', + tooltip: _autoScroll + ? context.l10n.logAutoScrollOn + : context.l10n.logAutoScrollOff, onPressed: () => setState(() => _autoScroll = !_autoScroll), ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy logs', + tooltip: context.l10n.logCopyLogs, onPressed: _copyLogs, ), PopupMenuButton( @@ -327,7 +329,7 @@ class _LogScreenState extends State { fillColor: colorScheme.surfaceContainerHighest, suffixIcon: _searchQuery.isNotEmpty ? IconButton( - tooltip: 'Clear search', + tooltip: context.l10n.logClearSearch, icon: const Icon(Icons.clear, size: 20), onPressed: () { _searchController.clear(); @@ -609,11 +611,9 @@ class _LogSummaryCard extends StatelessWidget { if (analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.block, - label: 'ISP BLOCKING DETECTED', - description: - 'Your ISP may be blocking access to download services', - suggestion: - 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', + label: context.l10n.logIssueIspBlockingLabel, + description: context.l10n.logIssueIspBlockingDescription, + suggestion: context.l10n.logIssueIspBlockingSuggestion, color: colorScheme.error, domains: analysis.blockedDomains, ), @@ -623,9 +623,9 @@ class _LogSummaryCard extends StatelessWidget { if (analysis.hasRateLimit) ...[ _IssueBadge( icon: Icons.speed, - label: 'RATE LIMITED', - description: 'Too many requests to the service', - suggestion: 'Wait a few minutes before trying again', + label: context.l10n.logIssueRateLimitedLabel, + description: context.l10n.logIssueRateLimitedDescription, + suggestion: context.l10n.logIssueRateLimitedSuggestion, color: Colors.orange, ), const SizedBox(height: 8), @@ -634,9 +634,9 @@ class _LogSummaryCard extends StatelessWidget { if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.wifi_off, - label: 'NETWORK ERROR', - description: 'Connection issues detected', - suggestion: 'Check your internet connection', + label: context.l10n.logIssueNetworkErrorLabel, + description: context.l10n.logIssueNetworkErrorDescription, + suggestion: context.l10n.logIssueNetworkErrorSuggestion, color: colorScheme.tertiary, ), const SizedBox(height: 8), @@ -645,11 +645,9 @@ class _LogSummaryCard extends StatelessWidget { if (analysis.hasNotFound) ...[ _IssueBadge( icon: Icons.search_off, - label: 'TRACK NOT FOUND', - description: - 'Some tracks could not be found on download services', - suggestion: - 'The track may not be available in lossless quality', + label: context.l10n.logIssueTrackNotFoundLabel, + description: context.l10n.logIssueTrackNotFoundDescription, + suggestion: context.l10n.logIssueTrackNotFoundSuggestion, color: colorScheme.onSurfaceVariant, ), ], diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 7f28e405..5bf30824 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -107,10 +107,10 @@ class OptionsSettingsPage extends ConsumerWidget { ), SettingsSwitchItem( icon: Icons.sell_outlined, - title: 'Embed Metadata', + title: context.l10n.optionsEmbedMetadata, subtitle: settings.embedMetadata - ? 'Write metadata, cover art, and embedded lyrics to files' - : 'Disabled (advanced): skip all metadata embedding', + ? context.l10n.optionsEmbedMetadataSubtitleOn + : context.l10n.optionsEmbedMetadataSubtitleOff, value: settings.embedMetadata, onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedMetadata(v), @@ -135,7 +135,7 @@ class OptionsSettingsPage extends ConsumerWidget { title: context.l10n.optionsMaxQualityCover, subtitle: settings.embedMetadata ? context.l10n.optionsMaxQualityCoverSubtitle - : 'Disabled when metadata embedding is off', + : context.l10n.optionsMaxQualityCoverSubtitleDisabled, value: settings.maxQualityCover, enabled: settings.embedMetadata, onChanged: (v) => ref diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index bc6b22d6..d373d580 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -749,7 +749,7 @@ class _SetupScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), trailing: IconButton( - tooltip: 'Change folder', + tooltip: context.l10n.setupChangeFolderTooltip, icon: const Icon(Icons.edit), onPressed: _selectDirectory, ), diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart index 9af4a7d6..b2dd2fc5 100644 --- a/lib/screens/store/extension_details_screen.dart +++ b/lib/screens/store/extension_details_screen.dart @@ -44,13 +44,18 @@ class _ExtensionDetailsScreenState _buildDescription(context, liveExtension, colorScheme), if (liveExtension.tags.isNotEmpty) ...[ - _buildSectionHeader(context, 'Tags', Icons.tag, colorScheme), + _buildSectionHeader( + context, + context.l10n.extensionDetailsTags, + Icons.tag, + colorScheme, + ), _buildTags(context, liveExtension, colorScheme), ], _buildSectionHeader( context, - 'Information', + context.l10n.extensionDetailsInformation, Icons.table_chart_outlined, colorScheme, ), @@ -438,7 +443,7 @@ class _ExtensionDetailsScreenState ), _CapabilityRow( icon: Icons.build, - label: 'Utility Functions', + label: context.l10n.extensionUtilityFunctions, enabled: isUtility, colorScheme: colorScheme, isLast: true, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index e4b69d3c..898dc8c0 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -6037,7 +6037,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const LinearProgressIndicator(minHeight: 2) else if (!hasCurrentCover) Text( - 'No embedded album art found', + context.l10n.trackCoverNoEmbeddedArt, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), @@ -6050,14 +6050,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { onPressed: _saving ? null : _pickCoverImage, icon: const Icon(Icons.image_outlined), label: Text( - hasSelectedCover ? 'Replace Cover' : 'Pick Cover', + hasSelectedCover + ? context.l10n.trackCoverReplace + : context.l10n.trackCoverPick, ), ), ), if (hasSelectedCover) ...[ const SizedBox(width: 8), IconButton( - tooltip: 'Clear selected cover', + tooltip: context.l10n.trackCoverClearSelected, onPressed: _saving ? null : () async { @@ -6079,7 +6081,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { child: _buildCoverPreviewTile( cs: cs, path: _currentCoverPath!, - label: 'Current cover', + label: context.l10n.trackCoverCurrent, ), ), if (hasCurrentCover && hasSelectedCover) @@ -6089,7 +6091,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { child: _buildCoverPreviewTile( cs: cs, path: _selectedCoverPath!, - label: _selectedCoverName ?? 'Selected cover', + label: + _selectedCoverName ?? + context.l10n.trackCoverSelected, ), ), ], @@ -6097,7 +6101,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (hasSelectedCover) ...[ const SizedBox(height: 8), Text( - 'The selected cover will replace the current embedded cover when you tap Save.', + context.l10n.trackCoverReplaceNotice, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index 2094b383..265d5137 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -408,7 +408,7 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { }, style: TextStyle(color: colorScheme.onSurface, fontSize: 16), decoration: InputDecoration( - hintText: 'Paste or search...', + hintText: context.l10n.tutorialSearchHint, hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), prefixIcon: Icon(Icons.search, color: colorScheme.primary), filled: true, @@ -619,10 +619,10 @@ class _InteractiveDownloadExampleState Semantics( button: true, label: _isCompleted - ? 'Download completed' + ? context.l10n.tutorialDownloadCompletedSemantics : _isDownloading - ? 'Download in progress' - : 'Start download', + ? context.l10n.tutorialDownloadInProgressSemantics + : context.l10n.tutorialStartDownloadSemantics, child: GestureDetector( onTap: _startDownload, child: AnimatedContainer( diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart index 8a06050a..763a784f 100644 --- a/lib/services/local_track_redownload_service.dart +++ b/lib/services/local_track_redownload_service.dart @@ -108,7 +108,7 @@ class LocalTrackRedownloadService { case 'deezer': return settings.defaultService.toLowerCase(); default: - return 'tidal'; + return ''; } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8a48a947..abbbacad 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -547,22 +547,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> getTidalMetadata( - String resourceType, - String resourceId, - ) async { - final result = await _channel.invokeMethod('getTidalMetadata', { - 'resource_type': resourceType, - 'resource_id': resourceId, - }); - if (result == null) { - throw Exception( - 'getTidalMetadata returned null for $resourceType:$resourceId', - ); - } - return jsonDecode(result as String) as Map; - } - static Future> getProviderMetadata( String providerId, String resourceType, @@ -581,15 +565,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> convertTidalToSpotifyDeezer( - String tidalUrl, - ) async { - final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', { - 'url': tidalUrl, - }); - return jsonDecode(result as String) as Map; - } - static Future> searchDeezerByISRC( String isrc, { String? itemId, diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 65130785..5afaa1ec 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; @@ -51,7 +52,7 @@ Future navigateToArtist( return; } - _showLoadingSnackBar(context, 'Looking up artist...'); + _showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist); try { final artistList = await _searchDeezerExtension( artistName, @@ -62,7 +63,7 @@ Future navigateToArtist( ScaffoldMessenger.of(context).hideCurrentSnackBar(); if (artistList.isEmpty) { - _showUnavailable(context, 'Artist'); + _showUnavailable(context, context.l10n.trackArtist); return; } @@ -82,7 +83,7 @@ Future navigateToArtist( final resolvedImage = bestMatch['images'] as String?; if (resolvedId.isEmpty) { - _showUnavailable(context, 'Artist'); + _showUnavailable(context, context.l10n.trackArtist); return; } @@ -98,7 +99,7 @@ Future navigateToArtist( _log.e('Failed to look up artist "$artistName": $e', e); if (!context.mounted) return; ScaffoldMessenger.of(context).hideCurrentSnackBar(); - _showUnavailable(context, 'Artist'); + _showUnavailable(context, context.l10n.trackArtist); } } @@ -290,7 +291,9 @@ void _showLoadingSnackBar(BuildContext context, String message) { void _showUnavailable(BuildContext context, String type) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text('$type information not available'))); + ).showSnackBar( + SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))), + ); } class ClickableArtistName extends StatefulWidget { diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index e06f5a79..498d081f 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -21,8 +21,8 @@ class BuiltInService { }); } -const _builtInServices = [ - BuiltInService( +const _builtInServiceCatalog = { + 'tidal': BuiltInService( id: 'tidal', label: 'Tidal', qualityOptions: [ @@ -43,7 +43,7 @@ const _builtInServices = [ ), ], ), - BuiltInService( + 'qobuz': BuiltInService( id: 'qobuz', label: 'Qobuz', qualityOptions: [ @@ -64,7 +64,7 @@ const _builtInServices = [ ), ], ), -]; +}; class DownloadServicePicker extends ConsumerStatefulWidget { final String? trackName; @@ -118,58 +118,93 @@ class DownloadServicePicker extends ConsumerStatefulWidget { class _DownloadServicePickerState extends ConsumerState { late String _selectedService; + List _availableBuiltInServices() { + final availableIds = builtInDownloadProviderIds.toSet(); + final services = []; + for (final id in availableIds) { + final service = _builtInServiceCatalog[id]; + if (service != null) { + services.add(service); + } + } + return services; + } + + List _downloadExtensions() { + final extensionState = ref.read(extensionProvider); + return extensionState.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .toList(growable: false); + } + + bool _serviceExists( + String serviceId, + List builtInServices, + List downloadExtensions, + ) { + if (serviceId.isEmpty) return false; + if (builtInServices.any((service) => service.id == serviceId)) return true; + return downloadExtensions.any((ext) => ext.id == serviceId); + } + @override void initState() { super.initState(); + final builtInServices = _availableBuiltInServices(); + final downloadExtensions = _downloadExtensions(); final recommended = widget.recommendedService; - if (recommended != null && recommended.isNotEmpty) { + if (recommended != null && + _serviceExists(recommended, builtInServices, downloadExtensions)) { _selectedService = recommended; } else { _selectedService = ref.read(settingsProvider).defaultService; } - if (!_builtInServices.any((service) => service.id == _selectedService)) { - final extensionState = ref.read(extensionProvider); - final hasMatchingExtension = extensionState.extensions.any( - (ext) => - ext.enabled && - ext.hasDownloadProvider && - ext.id == _selectedService, - ); - if (!hasMatchingExtension) { - _selectedService = 'tidal'; - } + if (!_serviceExists( + _selectedService, + builtInServices, + downloadExtensions, + )) { + _selectedService = builtInServices.isNotEmpty + ? builtInServices.first.id + : downloadExtensions.isNotEmpty + ? downloadExtensions.first.id + : ''; } } - List _getQualityOptions() { - final builtIn = _builtInServices - .where((s) => s.id == _selectedService) + List _getQualityOptions( + List builtInServices, + List downloadExtensions, + ) { + final builtIn = builtInServices + .where((service) => service.id == _selectedService) .firstOrNull; if (builtIn != null) { return builtIn.qualityOptions; } - final extensionState = ref.read(extensionProvider); - final ext = extensionState.extensions + final ext = downloadExtensions .where((e) => e.id == _selectedService) .firstOrNull; if (ext != null && ext.qualityOptions.isNotEmpty) { return ext.qualityOptions; } - return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions; + return const []; } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final extensionState = ref.watch(extensionProvider); - - final downloadExtensions = extensionState.extensions - .where((ext) => ext.enabled && ext.hasDownloadProvider) - .toList(); - - final qualityOptions = _getQualityOptions(); + ref.watch(extensionProvider); + final builtInServices = _availableBuiltInServices(); + final downloadExtensions = _downloadExtensions(); + final hasProviders = + builtInServices.isNotEmpty || downloadExtensions.isNotEmpty; + final qualityOptions = _getQualityOptions( + builtInServices, + downloadExtensions, + ); return SafeArea( child: SingleChildScrollView( @@ -213,68 +248,77 @@ class _DownloadServicePickerState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final service in _builtInServices) - _ServiceChip( - label: service.isDisabled - ? '${service.label} (${service.disabledReason})' - : widget.recommendedService == service.id - ? '${service.label} (Recommended)' - : service.label, - isSelected: _selectedService == service.id, - isDisabled: service.isDisabled, - onTap: service.isDisabled - ? null - : () => setState(() => _selectedService = service.id), + child: hasProviders + ? Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final service in builtInServices) + _ServiceChip( + label: service.isDisabled + ? '${service.label} (${service.disabledReason})' + : widget.recommendedService == service.id + ? '${service.label} (Recommended)' + : service.label, + isSelected: _selectedService == service.id, + isDisabled: service.isDisabled, + onTap: service.isDisabled + ? null + : () => setState( + () => _selectedService = service.id, + ), + ), + for (final ext in downloadExtensions) + _ServiceChip( + label: widget.recommendedService == ext.id + ? '${ext.displayName} (Recommended)' + : ext.displayName, + isSelected: _selectedService == ext.id, + onTap: () => + setState(() => _selectedService = ext.id), + iconPath: ext.iconPath, + ), + ], + ) + : _NoDownloadProviderHint( + primaryText: context.l10n.extensionsNoDownloadProvider, + secondaryText: context.l10n.storeAddRepoDescription, ), - for (final ext in downloadExtensions) - _ServiceChip( - label: widget.recommendedService == ext.id - ? '${ext.displayName} (Recommended)' - : ext.displayName, - isSelected: _selectedService == ext.id, - onTap: () => setState(() => _selectedService = ext.id), - iconPath: ext.iconPath, - ), - ], - ), ), - - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text( - context.l10n.downloadSelectQuality, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - - if (_builtInServices.any((s) => s.id == _selectedService)) + if (hasProviders) ...[ Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text( - context.l10n.qualityNote, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + context.l10n.downloadSelectQuality, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), ), ), - - for (final quality in qualityOptions) - _QualityOption( - title: quality.label, - subtitle: quality.description ?? '', - icon: _getQualityIcon(quality.id), - onTap: () { - Navigator.pop(context); - widget.onSelect(quality.id, _selectedService); - }, - ), + if (builtInServices.any( + (service) => service.id == _selectedService, + )) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + context.l10n.qualityNote, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + for (final quality in qualityOptions) + _QualityOption( + title: _localizedQualityLabel(context, quality), + subtitle: _localizedQualityDescription(context, quality), + icon: _getQualityIcon(quality.id), + onTap: () { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + }, + ), + ], const SizedBox(height: 16), ], @@ -303,6 +347,35 @@ class _DownloadServicePickerState extends ConsumerState { return Icons.music_note; } } + + String _localizedQualityLabel(BuildContext context, QualityOption quality) { + switch (quality.id.toUpperCase()) { + case 'LOSSLESS': + return context.l10n.qualityFlacLossless; + case 'HI_RES': + return context.l10n.qualityHiResFlac; + case 'HI_RES_LOSSLESS': + return context.l10n.qualityHiResFlacMax; + default: + return quality.label; + } + } + + String _localizedQualityDescription( + BuildContext context, + QualityOption quality, + ) { + switch (quality.id.toUpperCase()) { + case 'LOSSLESS': + return context.l10n.qualityFlacLosslessSubtitle; + case 'HI_RES': + return context.l10n.qualityHiResFlacSubtitle; + case 'HI_RES_LOSSLESS': + return context.l10n.qualityHiResFlacMaxSubtitle; + default: + return quality.description ?? ''; + } + } } class _QualityOption extends StatelessWidget { @@ -421,6 +494,63 @@ class _ServiceChip extends StatelessWidget { } } +class _NoDownloadProviderHint extends StatelessWidget { + final String primaryText; + final String secondaryText; + + const _NoDownloadProviderHint({ + required this.primaryText, + required this.secondaryText, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.extension_outlined, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + primaryText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + secondaryText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + class _TrackInfoHeader extends StatefulWidget { final String trackName; final String? artistName;