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;