diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 638f5065..53a05205 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -158,6 +158,115 @@ func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (Downlo return spec.Download(req) } +func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string { + if manifest == nil || manifest.Capabilities == nil { + return nil + } + + raw, ok := manifest.Capabilities[key] + if !ok { + return nil + } + + values, ok := raw.([]interface{}) + if !ok { + return nil + } + + result := make([]string, 0, len(values)) + for _, value := range values { + str, ok := value.(string) + if !ok { + continue + } + trimmed := strings.ToLower(strings.TrimSpace(str)) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} + +func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool { + if ext == nil { + return false + } + + normalized := strings.ToLower(strings.TrimSpace(providerID)) + if normalized == "" { + return false + } + + for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") { + if replaced == normalized { + return true + } + } + + return false +} + +func trimKnownProviderPrefix(trackID, providerID string) string { + trimmedID := strings.TrimSpace(trackID) + normalizedProvider := strings.ToLower(strings.TrimSpace(providerID)) + if trimmedID == "" || normalizedProvider == "" { + return trimmedID + } + + prefix := normalizedProvider + ":" + if strings.HasPrefix(strings.ToLower(trimmedID), prefix) { + return trimmedID[len(prefix):] + } + + return trimmedID +} + +func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string { + candidates := make([]string, 0, 8) + appendCandidate := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + for _, existing := range candidates { + if existing == trimmed { + return + } + } + candidates = append(candidates, trimmed) + } + + appendCandidate(explicitTrackID) + + if extensionReplacesBuiltInProvider(ext, "tidal") { + appendCandidate(req.TidalID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal")) + } + if extensionReplacesBuiltInProvider(ext, "qobuz") { + appendCandidate(req.QobuzID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz")) + } + if extensionReplacesBuiltInProvider(ext, "deezer") { + appendCandidate(req.DeezerID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer")) + } + if extensionReplacesBuiltInProvider(ext, "spotify") { + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify")) + appendCandidate(req.SpotifyID) + } + + appendCandidate(req.SpotifyID) + appendCandidate(req.TidalID) + appendCandidate(req.QobuzID) + appendCandidate(req.DeezerID) + + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { if result == nil { return DownloadResult{}, false @@ -317,6 +426,13 @@ func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtA } } +func shouldAbortCancelledFallback(itemID string, err error) bool { + if errors.Is(err, ErrDownloadCancelled) { + return true + } + return itemID != "" && isDownloadCancelled(itemID) +} + type DownloadDecryptionInfo struct { Strategy string `json:"strategy,omitempty"` Key string `json:"key,omitempty"` @@ -824,11 +940,11 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, return &enrichedTrack, nil } -func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { - return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, "") +func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string) (*ExtAvailabilityResult, error) { + return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, "") } -func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID string, itemID string) (*ExtAvailabilityResult, error) { +func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, itemID string) (*ExtAvailabilityResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -855,11 +971,16 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { - return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q}); + return extension.checkAvailability(%q, %q, %q, { + spotify_id: %q, + deezer_id: %q, + tidal_id: %q, + qobuz_id: %q + }); } return null; })() - `, isrc, trackName, artistName, spotifyID, deezerID) + `, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { @@ -966,6 +1087,7 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID if itemID != "" { initDownloadCancel(itemID) defer clearDownloadCancel(itemID) + SetItemPreparing(itemID) } p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { @@ -1585,8 +1707,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro ext, err := extManager.GetExtension(req.Source) if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { provider := newExtensionProviderWrapper(ext) - availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.ItemID) - if errors.Is(availErr, ErrDownloadCancelled) { + availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, availErr) { return nil, ErrDownloadCancelled } if availErr != nil { @@ -1624,7 +1746,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID) - if errors.Is(err, ErrDownloadCancelled) { + if shouldAbortCancelledFallback(req.ItemID, err) { return nil, ErrDownloadCancelled } if err == nil && enrichedTrack != nil { @@ -1716,7 +1838,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery) tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 5, true, req.ItemID) - if errors.Is(searchErr, ErrDownloadCancelled) { + if shouldAbortCancelledFallback(req.ItemID, searchErr) { return nil, ErrDownloadCancelled } if searchErr == nil && len(tracks) > 0 { @@ -1770,6 +1892,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") { enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright) + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } } } @@ -1793,10 +1918,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro provider := newExtensionProviderWrapper(ext) - trackID := req.SpotifyID - if sourceExtensionTrackID != "" { - trackID = sourceExtensionTrackID - } + trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID) GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) @@ -1824,6 +1946,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro RemoveItemProgress(req.ItemID) } } + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } if err == nil && result.Success { normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) @@ -1994,8 +2119,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro provider := newExtensionProviderWrapper(ext) - availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.ItemID) - if errors.Is(err, ErrDownloadCancelled) { + availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, err) { return nil, ErrDownloadCancelled } terminalAvailability := shouldStopProviderFallback(availability) @@ -2036,6 +2161,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro RemoveItemProgress(req.ItemID) } } + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } if err == nil && result.Success { normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index f4d5b682..cff9b1d3 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -211,6 +211,24 @@ func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) { } } +func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) { + if !shouldAbortCancelledFallback("", ErrDownloadCancelled) { + t.Fatal("expected cancelled error to abort fallback") + } +} + +func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) { + const itemID = "cancelled-item" + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + + cancelDownload(itemID) + + if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) { + t.Fatal("expected cancelled item state to abort fallback even for generic errors") + } +} + func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) { tempFile := filepath.Join(t.TempDir(), "track.flac") if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil { diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 05c22798..ce4fe367 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -479,6 +479,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("appUserAgent", r.appUserAgent) utilsObj.Set("sleep", r.sleep) utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled) + utilsObj.Set("setDownloadStatus", r.setDownloadStatus) vm.Set("utils", utilsObj) logObj := vm.NewObject() diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 7a54e26d..96a55308 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -205,14 +205,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } defer out.Close() - contentLength := resp.ContentLength activeItemID := r.getActiveDownloadItemID() - if activeItemID != "" && contentLength > 0 { + if activeItemID != "" { + SetItemDownloading(activeItemID) + } + + contentLength := resp.ContentLength + shouldTrackItemBytes := activeItemID != "" && onProgress == nil + if shouldTrackItemBytes && contentLength > 0 { SetItemBytesTotal(activeItemID, contentLength) } var progressWriter interface{ Write([]byte) (int, error) } = out - if activeItemID != "" { + if shouldTrackItemBytes { progressWriter = NewItemProgressWriter(out, activeItemID) } diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index 5689b1d2..2a3a9877 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -312,6 +312,25 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu return r.vm.ToValue(isDownloadCancelled(itemID)) } +func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value { + itemID := r.getActiveDownloadItemID() + if itemID == "" || len(call.Arguments) < 1 { + return goja.Undefined() + } + + status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String())) + switch status { + case itemProgressStatusPreparing: + SetItemPreparing(itemID) + case itemProgressStatusDownloading: + SetItemDownloading(itemID) + case itemProgressStatusFinalizing: + SetItemFinalizing(itemID) + } + + return goja.Undefined() +} + func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value { msg := r.formatLogArgs(call.Arguments) GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg) diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index c16321d6..0e0ae60c 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -81,6 +81,8 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{ MusixmatchLanguage: "", } +var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`) + var ( lyricsFetchOptionsMu sync.RWMutex lyricsFetchOptions = defaultLyricsFetchOptions @@ -411,6 +413,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st primaryArtist := normalizeArtistName(artistName) fetchOptions := GetLyricsFetchOptions() + if isLikelyInstrumentalTrack(trackName) { + GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName) + instrumental := &LyricsResponse{ + Instrumental: true, + Source: "Heuristic: Instrumental", + } + globalLyricsCache.Set(artistName, trackName, durationSec, instrumental) + return instrumental, nil + } + extManager := getExtensionManager() var extensionProviders []*extensionProviderWrapper if extManager != nil { @@ -847,6 +859,15 @@ func normalizeArtistName(name string) string { return strings.TrimSpace(result) } +func isLikelyInstrumentalTrack(name string) bool { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return false + } + + return instrumentalTrackPattern.MatchString(trimmed) +} + func SaveLRCFile(audioFilePath, lrcContent string) (string, error) { if lrcContent == "" { return "", fmt.Errorf("empty LRC content") diff --git a/go_backend/progress.go b/go_backend/progress.go index 2117d242..428d0fa1 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -26,6 +26,13 @@ type ItemProgress struct { Status string `json:"status"` } +const ( + itemProgressStatusPreparing = "preparing" + itemProgressStatusDownloading = "downloading" + itemProgressStatusCompleted = "completed" + itemProgressStatusFinalizing = "finalizing" +) + type MultiProgress struct { Items map[string]*ItemProgress `json:"items"` } @@ -106,11 +113,37 @@ func StartItemProgress(itemID string) { BytesReceived: 0, Progress: 0, IsDownloading: true, - Status: "downloading", + Status: itemProgressStatusDownloading, } markMultiProgressDirtyLocked() } +func SetItemPreparing(itemID string) { + multiMu.Lock() + defer multiMu.Unlock() + + if item, ok := multiProgress.Items[itemID]; ok { + item.Progress = 0 + item.BytesReceived = 0 + item.BytesTotal = 0 + item.SpeedMBps = 0 + item.IsDownloading = true + item.Status = itemProgressStatusPreparing + markMultiProgressDirtyLocked() + } +} + +func SetItemDownloading(itemID string) { + multiMu.Lock() + defer multiMu.Unlock() + + if item, ok := multiProgress.Items[itemID]; ok { + item.IsDownloading = true + item.Status = itemProgressStatusDownloading + markMultiProgressDirtyLocked() + } +} + func SetItemBytesTotal(itemID string, total int64) { multiMu.Lock() defer multiMu.Unlock() @@ -155,7 +188,7 @@ func CompleteItemProgress(itemID string) { if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 item.IsDownloading = false - item.Status = "completed" + item.Status = itemProgressStatusCompleted markMultiProgressDirtyLocked() } } @@ -182,7 +215,7 @@ func SetItemFinalizing(itemID string) { if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 - item.Status = "finalizing" + item.Status = itemProgressStatusFinalizing markMultiProgressDirtyLocked() } } diff --git a/go_backend/progress_test.go b/go_backend/progress_test.go new file mode 100644 index 00000000..e9d62d3b --- /dev/null +++ b/go_backend/progress_test.go @@ -0,0 +1,59 @@ +package gobackend + +import "testing" + +func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) { + const itemID = "progress-phase-item" + RemoveItemProgress(itemID) + defer RemoveItemProgress(itemID) + + StartItemProgress(itemID) + SetItemPreparing(itemID) + + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist") + } else { + if item.Status != itemProgressStatusPreparing { + t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusPreparing) + } + if item.Progress != 0 { + t.Fatalf("progress = %v, want 0", item.Progress) + } + } + + SetItemProgress(itemID, 0.37, 0, 0) + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist after update") + } else if item.Status != itemProgressStatusPreparing { + t.Fatalf("status after progress update = %q, want %q", item.Status, itemProgressStatusPreparing) + } + + SetItemDownloading(itemID) + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist after downloading status") + } else if item.Status != itemProgressStatusDownloading { + t.Fatalf("status after download start = %q, want %q", item.Status, itemProgressStatusDownloading) + } +} + +func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) { + const itemID = "progress-finalizing-item" + RemoveItemProgress(itemID) + defer RemoveItemProgress(itemID) + + StartItemProgress(itemID) + SetItemFinalizing(itemID) + + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist") + } else if item.Status != itemProgressStatusFinalizing { + t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusFinalizing) + } + + CompleteItemProgress(itemID) + if item := multiProgress.Items[itemID]; item == nil { + t.Fatal("expected item progress entry to exist after completion") + } else if item.Status != itemProgressStatusCompleted { + t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusCompleted) + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index dde1706b..ad3e46d9 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1711,6 +1711,21 @@ class DownloadQueueNotifier extends Notifier { continue; } + if (status == 'preparing') { + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.downloading, + progress: 0.0, + speedMBps: 0, + bytesReceived: 0, + bytesTotal: 0, + ); + + if (LogBuffer.loggingEnabled) { + _log.d('Preparing [$itemId]: waiting for real download bytes'); + } + continue; + } + final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; @@ -1823,6 +1838,7 @@ class DownloadQueueNotifier extends Notifier { final bytesReceived = (firstProgress['bytes_received'] as num?)?.toInt() ?? 0; final bytesTotal = (firstProgress['bytes_total'] as num?)?.toInt() ?? 0; + final backendStatus = firstProgress['status'] as String? ?? 'downloading'; if (downloadingCount > 0 && firstDownloading != null) { final trackName = downloadingCount == 1 @@ -1835,7 +1851,10 @@ class DownloadQueueNotifier extends Notifier { int notifProgress = bytesReceived; int notifTotal = bytesTotal; - if (bytesTotal <= 0) { + if (backendStatus == 'preparing') { + notifProgress = 0; + notifTotal = 100; + } else if (bytesTotal <= 0) { final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; notifProgress = (progressPercent * 100).toInt(); @@ -2620,11 +2639,17 @@ class DownloadQueueNotifier extends Notifier { try { final colonIdx = track.id.indexOf(':'); final provider = track.id.substring(0, colonIdx); + final effectiveProvider = resolveEffectiveMetadataProvider( + provider, + ref.read(extensionProvider), + ); final providerTrackId = track.id.substring(colonIdx + 1); - _log.d('No ISRC, fetching from $provider API: $providerTrackId'); + _log.d( + 'No ISRC, fetching from ${effectiveProvider.isEmpty ? provider : effectiveProvider} API: $providerTrackId', + ); final providerData = await PlatformBridge.getProviderMetadata( - provider, + effectiveProvider.isEmpty ? provider : effectiveProvider, 'track', providerTrackId, ); @@ -2647,7 +2672,9 @@ class DownloadQueueNotifier extends Notifier { ); } - _log.d('Resolved ISRC from $provider: $resolvedIsrc'); + _log.d( + 'Resolved ISRC from ${effectiveProvider.isEmpty ? provider : effectiveProvider}: $resolvedIsrc', + ); final updatedTrack = _copyTrackWithResolvedMetadata( track, @@ -2661,7 +2688,8 @@ class DownloadQueueNotifier extends Notifier { ); final deezerTrackId = await _searchDeezerTrackIdByIsrc( resolvedIsrc, - lookupContext: '$provider ISRC', + lookupContext: + '${effectiveProvider.isEmpty ? provider : effectiveProvider} ISRC', itemId: itemId, ); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index b55a062b..f349ddaf 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -345,6 +345,50 @@ String resolveEffectiveDownloadService( ''; } +String resolveEffectiveMetadataProvider( + String requestedProvider, + ExtensionState extensionState, +) { + final normalizedRequested = requestedProvider.trim().toLowerCase(); + final builtInMetadataIds = extensionState.builtInProviders + .where((provider) => provider.supportsMetadata) + .map((provider) => provider.id.trim().toLowerCase()) + .where((providerId) => providerId.isNotEmpty) + .toSet(); + final enabledMetadataExtensions = extensionState.extensions + .where((ext) => ext.enabled && ext.hasMetadataProvider) + .toList(growable: false); + + if (normalizedRequested.isNotEmpty) { + if (builtInMetadataIds.contains(normalizedRequested)) { + return normalizedRequested; + } + + final matchingExtension = enabledMetadataExtensions + .where((ext) => ext.id.trim().toLowerCase() == normalizedRequested) + .firstOrNull; + if (matchingExtension != null) { + return matchingExtension.id; + } + + final replacementExtension = enabledMetadataExtensions + .where( + (ext) => ext.replacesBuiltInProviders.contains(normalizedRequested), + ) + .firstOrNull; + if (replacementExtension != null) { + return replacementExtension.id; + } + } + + return enabledMetadataExtensions.firstOrNull?.id ?? + extensionState.builtInProviders + .where((provider) => provider.supportsMetadata) + .map((provider) => provider.id) + .firstOrNull ?? + ''; +} + bool isDeezerCompatibleDownloadService( String service, ExtensionState extensionState, @@ -1024,6 +1068,23 @@ class ExtensionNotifier extends Notifier { .firstOrNull; } + String? _firstEnabledSearchProviderId() { + return state.extensions + .where( + (ext) => + ext.enabled && + ext.hasCustomSearch && + ext.searchBehavior?.primary == true, + ) + .map((ext) => ext.id) + .firstOrNull ?? + state.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .map((ext) => ext.id) + .firstOrNull ?? + defaultBuiltInSearchProviderId; + } + String? replacedBuiltInDownloadProviderFor(String providerId) { final normalized = providerId.trim().toLowerCase(); if (normalized.isEmpty) return null; @@ -1137,8 +1198,18 @@ class ExtensionNotifier extends Notifier { void _reconcileSearchProvider() { final settings = ref.read(settingsProvider); - final currentSearchProvider = settings.searchProvider?.trim(); - if (currentSearchProvider == null || currentSearchProvider.isEmpty) { + final currentSearchProvider = settings.searchProvider?.trim() ?? ''; + final preferredSearchProvider = _firstEnabledSearchProviderId() ?? ''; + + if (currentSearchProvider.isEmpty) { + if (preferredSearchProvider.isNotEmpty) { + ref + .read(settingsProvider.notifier) + .setSearchProvider(preferredSearchProvider); + _log.d( + 'Adopted first enabled search provider as default: $preferredSearchProvider', + ); + } return; } @@ -1161,9 +1232,15 @@ class ExtensionNotifier extends Notifier { ); if (!isBuiltInSearchProvider(currentSearchProvider) && !hasMatchingExtension) { - ref.read(settingsProvider.notifier).setSearchProvider(null); + ref + .read(settingsProvider.notifier) + .setSearchProvider( + preferredSearchProvider.isNotEmpty ? preferredSearchProvider : null, + ); _log.d( - 'Cleared stale search provider because $currentSearchProvider is no longer available', + preferredSearchProvider.isNotEmpty + ? 'Reset stale search provider $currentSearchProvider to $preferredSearchProvider' + : 'Cleared stale search provider because $currentSearchProvider is no longer available', ); } } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f7c81ff5..8256028f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -80,6 +80,32 @@ class _AlbumScreenState extends ConsumerState { int? _albumTotalTracks; final ScrollController _scrollController = ScrollController(); + String _legacyProviderIdFromResourceId(String value) { + if (value.startsWith('deezer:')) return 'deezer'; + if (value.startsWith('qobuz:')) return 'qobuz'; + if (value.startsWith('tidal:')) return 'tidal'; + if (value.startsWith('spotify:')) return 'spotify'; + return 'spotify'; + } + + String _effectiveMetadataProviderIdFromAlbumId() { + if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + return widget.extensionId!; + } + return resolveEffectiveMetadataProvider( + _legacyProviderIdFromResourceId(widget.albumId), + ref.read(extensionProvider), + ); + } + + String _stripPrefixedResourceId(String value) { + final colonIndex = value.indexOf(':'); + if (colonIndex <= 0 || colonIndex == value.length - 1) { + return value; + } + return value.substring(colonIndex + 1); + } + @override void initState() { super.initState(); @@ -87,14 +113,7 @@ class _AlbumScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - final providerId = - widget.extensionId ?? - (() { - if (widget.albumId.startsWith('deezer:')) return 'deezer'; - if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; - if (widget.albumId.startsWith('tidal:')) return 'tidal'; - return 'spotify'; - })(); + final providerId = _effectiveMetadataProviderIdFromAlbumId(); ref .read(recentAccessProvider.notifier) .recordAlbumAccess( @@ -263,21 +282,12 @@ class _AlbumScreenState extends ConsumerState { } String? _directMetadataProviderId() { - if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { - return widget.extensionId; - } - if (widget.albumId.startsWith('deezer:')) return 'deezer'; - if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; - if (widget.albumId.startsWith('tidal:')) return 'tidal'; - return null; + final providerId = _effectiveMetadataProviderIdFromAlbumId(); + return providerId.isEmpty ? null : providerId; } String _metadataResourceId(String providerId) { - final prefixed = '$providerId:'; - if (widget.albumId.startsWith(prefixed)) { - return widget.albumId.substring(prefixed.length); - } - return widget.albumId; + return _stripPrefixedResourceId(widget.albumId); } Track _parseTrack( @@ -656,9 +666,7 @@ class _AlbumScreenState extends ConsumerState { ); return; } - ref - .read(downloadQueueProvider.notifier) - .addToQueue(track, service); + ref.read(downloadQueueProvider.notifier).addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 11312961..757bba59 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -158,22 +158,39 @@ class _ArtistScreenState extends ConsumerState { return _directMetadataProviderId(); } - String? _directMetadataProviderId() { + String _legacyProviderIdFromResourceId(String value) { + if (value.startsWith('deezer:')) return 'deezer'; + if (value.startsWith('qobuz:')) return 'qobuz'; + if (value.startsWith('tidal:')) return 'tidal'; + if (value.startsWith('spotify:')) return 'spotify'; + return 'spotify'; + } + + String _effectiveMetadataProviderIdFromArtistId() { if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { - return widget.extensionId; + return widget.extensionId!; } - if (widget.artistId.startsWith('deezer:')) return 'deezer'; - if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; - if (widget.artistId.startsWith('tidal:')) return 'tidal'; - return null; + return resolveEffectiveMetadataProvider( + _legacyProviderIdFromResourceId(widget.artistId), + ref.read(extensionProvider), + ); + } + + String _stripPrefixedResourceId(String value) { + final colonIndex = value.indexOf(':'); + if (colonIndex <= 0 || colonIndex == value.length - 1) { + return value; + } + return value.substring(colonIndex + 1); + } + + String? _directMetadataProviderId() { + final providerId = _effectiveMetadataProviderIdFromArtistId(); + return providerId.isEmpty ? null : providerId; } String _metadataResourceId(String providerId) { - final prefixed = '$providerId:'; - if (widget.artistId.startsWith(prefixed)) { - return widget.artistId.substring(prefixed.length); - } - return widget.artistId; + return _stripPrefixedResourceId(widget.artistId); } @override @@ -183,14 +200,7 @@ class _ArtistScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - final providerId = - widget.extensionId ?? - (() { - if (widget.artistId.startsWith('deezer:')) return 'deezer'; - if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; - if (widget.artistId.startsWith('tidal:')) return 'tidal'; - return 'spotify'; - })(); + final providerId = _effectiveMetadataProviderIdFromArtistId(); ref .read(recentAccessProvider.notifier) .recordArtistAccess( @@ -1034,9 +1044,7 @@ class _ArtistScreenState extends ConsumerState { Future> _fetchAlbumTracks(ArtistAlbum album) async { final providerId = album.providerId; if (providerId != null && providerId.isNotEmpty) { - final resourceId = album.id.startsWith('$providerId:') - ? album.id.substring(providerId.length + 1) - : album.id; + final resourceId = _stripPrefixedResourceId(album.id); final metadata = await PlatformBridge.getProviderMetadata( providerId, 'album', diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 97505f6f..5b9002a2 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -3643,20 +3643,17 @@ class _SearchProviderDropdown extends ConsumerWidget { .where((ext) => ext.enabled && ext.hasCustomSearch) .toList(); final builtInProviders = builtInSearchProviderSpecs; - final primarySearchExtension = _defaultSearchExtension(searchProviders); - final defaultProviderTarget = - primarySearchExtension?.displayName ?? - defaultBuiltInSearchProviderDisplayName ?? - context.l10n.extensionDefaultProvider; - final defaultProviderLabel = - '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; - final defaultProviderIconPath = primarySearchExtension?.iconPath; - final currentProvider = + final resolvedCurrentProvider = rawCurrentProvider != null && rawCurrentProvider.isNotEmpty && (isBuiltInSearchProvider(rawCurrentProvider) || searchProviders.any((e) => e.id == rawCurrentProvider)) ? rawCurrentProvider + : _defaultSearchExtension(searchProviders)?.id ?? + defaultBuiltInSearchProviderId; + final currentProvider = + resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty + ? resolvedCurrentProvider : null; Extension? currentExt; @@ -3676,19 +3673,6 @@ class _SearchProviderDropdown extends ConsumerWidget { if (currentExt.searchBehavior?.icon != null) { displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); } - } else if (primarySearchExtension?.searchBehavior?.icon != null) { - displayIcon = _getIconFromName( - primarySearchExtension!.searchBehavior!.icon!, - ); - iconPath = defaultProviderIconPath; - } else if (defaultProviderIconPath != null && - defaultProviderIconPath.isNotEmpty) { - iconPath = defaultProviderIconPath; - if (primarySearchExtension?.searchBehavior?.icon != null) { - displayIcon = _getIconFromName( - primarySearchExtension!.searchBehavior!.icon!, - ); - } } else if (isBuiltInProvider) { displayIcon = resolveProviderIcon(currentProvider); } @@ -3724,39 +3708,10 @@ class _SearchProviderDropdown extends ConsumerWidget { offset: const Offset(0, 40), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onSelected: (String providerId) { - final provider = providerId.isEmpty ? null : providerId; - ref.read(settingsProvider.notifier).setSearchProvider(provider); + ref.read(settingsProvider.notifier).setSearchProvider(providerId); onProviderChanged?.call(); }, itemBuilder: (context) => [ - PopupMenuItem( - value: '', - child: Row( - children: [ - Icon( - Icons.music_note, - size: 20, - color: currentProvider == null || currentProvider.isEmpty - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - defaultProviderLabel, - style: TextStyle( - fontWeight: - currentProvider == null || currentProvider.isEmpty - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - if (currentProvider == null || currentProvider.isEmpty) - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], - ), - ), ...builtInProviders.map( (provider) => PopupMenuItem( value: provider.id, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index b4305ac1..b1f192eb 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -52,19 +52,33 @@ class _PlaylistScreenState extends ConsumerState { String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; - String? _metadataProviderId(String playlistId) { - if (playlistId.startsWith('deezer:')) return 'deezer'; - if (playlistId.startsWith('qobuz:')) return 'qobuz'; - if (playlistId.startsWith('tidal:')) return 'tidal'; + String? _legacyProviderIdFromResourceId(String value) { + if (value.startsWith('deezer:')) return 'deezer'; + if (value.startsWith('qobuz:')) return 'qobuz'; + if (value.startsWith('tidal:')) return 'tidal'; return null; } - String _metadataResourceId(String providerId, String playlistId) { - final prefixed = '$providerId:'; - if (playlistId.startsWith(prefixed)) { - return playlistId.substring(prefixed.length); + String _stripPrefixedResourceId(String value) { + final colonIndex = value.indexOf(':'); + if (colonIndex <= 0 || colonIndex == value.length - 1) { + return value; } - return playlistId; + return value.substring(colonIndex + 1); + } + + String? _metadataProviderId(String playlistId) { + final providerId = _legacyProviderIdFromResourceId(playlistId); + if (providerId == null) return null; + final effective = resolveEffectiveMetadataProvider( + providerId, + ref.read(extensionProvider), + ); + return effective.isEmpty ? null : effective; + } + + String _metadataResourceId(String providerId, String playlistId) { + return _stripPrefixedResourceId(playlistId); } String? _recommendedDownloadService() { @@ -75,8 +89,13 @@ class _PlaylistScreenState extends ConsumerState { final playlistId = widget.playlistId; if (playlistId != null) { - if (playlistId.startsWith('tidal:')) return 'tidal'; - if (playlistId.startsWith('qobuz:')) return 'qobuz'; + final providerId = _metadataProviderId(playlistId); + if (providerId != null && providerId.isNotEmpty) { + return resolveEffectiveDownloadService( + providerId, + ref.read(extensionProvider), + ); + } } final source = _tracks.firstOrNull?.source; @@ -85,8 +104,13 @@ class _PlaylistScreenState extends ConsumerState { } final trackId = _tracks.firstOrNull?.id ?? ''; - if (trackId.startsWith('tidal:')) return 'tidal'; - if (trackId.startsWith('qobuz:')) return 'qobuz'; + final trackProviderId = _legacyProviderIdFromResourceId(trackId); + if (trackProviderId != null) { + return resolveEffectiveDownloadService( + trackProviderId, + ref.read(extensionProvider), + ); + } return null; } @@ -509,11 +533,7 @@ class _PlaylistScreenState extends ConsumerState { } ref .read(downloadQueueProvider.notifier) - .addToQueue( - track, - service, - playlistName: _playlistName, - ); + .addToQueue(track, service, playlistName: _playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 99341fc0..d42b5be9 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -674,18 +674,19 @@ class _SearchProviderSelector extends ConsumerWidget { final hasAnyProvider = searchProviders.isNotEmpty || builtInProviders.isNotEmpty; - String currentProviderName = context.l10n.extensionDefaultProvider; - if (settings.searchProvider != null && - settings.searchProvider!.isNotEmpty) { - if (isBuiltInSearchProvider(settings.searchProvider)) { - currentProviderName = resolveProviderDisplayName( - settings.searchProvider!, - ); + final resolvedProviderId = + (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) + ? settings.searchProvider! + : searchProviders.firstOrNull?.id ?? defaultBuiltInSearchProviderId; + String currentProviderName = context.l10n.optionsPrimaryProviderSubtitle; + if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) { + if (isBuiltInSearchProvider(resolvedProviderId)) { + currentProviderName = resolveProviderDisplayName(resolvedProviderId); } else { final ext = searchProviders - .where((e) => e.id == settings.searchProvider) + .where((e) => e.id == resolvedProviderId) .firstOrNull; - currentProviderName = ext?.displayName ?? settings.searchProvider!; + currentProviderName = ext?.displayName ?? resolvedProviderId; } } @@ -788,20 +789,6 @@ class _SearchProviderSelector extends ConsumerWidget { ), ), ), - ListTile( - leading: Icon(Icons.music_note, color: colorScheme.primary), - title: Text(ctx.l10n.extensionDefaultProvider), - subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), - trailing: - (settings.searchProvider == null || - settings.searchProvider!.isEmpty) - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - onTap: () { - ref.read(settingsProvider.notifier).setSearchProvider(null); - Navigator.pop(ctx); - }, - ), ...builtInProviders.map( (provider) => ListTile( leading: Icon(Icons.search, color: colorScheme.tertiary), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 5bf30824..5ed608ab 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -741,13 +741,6 @@ class _MetadataSourceSelector extends ConsumerWidget { final rawSearchProvider = settings.searchProvider?.trim() ?? ''; final isValidBuiltIn = isBuiltInSearchProvider(rawSearchProvider); - final primarySearchExtension = _defaultSearchExtension(extState.extensions); - final defaultProviderTarget = - primarySearchExtension?.displayName ?? - defaultBuiltInSearchProviderDisplayName ?? - context.l10n.extensionDefaultProvider; - final defaultProviderLabel = - '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; final searchProvider = isValidBuiltIn || extState.extensions.any( @@ -755,7 +748,9 @@ class _MetadataSourceSelector extends ConsumerWidget { e.enabled && e.hasCustomSearch && e.id == rawSearchProvider, ) ? rawSearchProvider - : ''; + : _defaultSearchExtension(extState.extensions)?.id ?? + defaultBuiltInSearchProviderId ?? + ''; final isBuiltIn = isBuiltInSearchProvider(searchProvider); Extension? activeExtension; @@ -764,7 +759,6 @@ class _MetadataSourceSelector extends ConsumerWidget { .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; } - final hasNonDefaultProvider = isBuiltIn || activeExtension != null; String subtitle; if (isBuiltIn) { @@ -792,7 +786,7 @@ class _MetadataSourceSelector extends ConsumerWidget { Text( subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: hasNonDefaultProvider + color: searchProvider.isNotEmpty ? colorScheme.primary : colorScheme.onSurfaceVariant, ), @@ -802,16 +796,6 @@ class _MetadataSourceSelector extends ConsumerWidget { spacing: 8, runSpacing: 8, children: [ - _SourceChip( - icon: Icons.graphic_eq, - label: defaultProviderLabel, - isSelected: searchProvider.isEmpty, - onTap: () { - if (hasNonDefaultProvider) { - ref.read(settingsProvider.notifier).setSearchProvider(null); - } - }, - ), for (final provider in builtInProviders) _SourceChip( icon: resolveProviderIcon( @@ -840,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget { const SizedBox(width: 8), Expanded( child: Text( - 'Tap $defaultProviderLabel to switch back from extension', + context.l10n.optionsSwitchBack, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ),