feat: add generic extension provider resolution, progress phases, and instrumental lyrics heuristic

- Replace hardcoded provider prefix checks with resolveEffectiveMetadataProvider using replacesBuiltInProviders manifest capability
- Add preparing/downloading/finalizing progress status constants and SetItemPreparing/SetItemDownloading APIs
- Expose setDownloadStatus to extension JS runtime for fine-grained progress control
- Skip lyrics search for instrumental tracks detected by title heuristic
- Pass tidal/qobuz IDs to extension checkAvailability for richer matching
- Add shouldAbortCancelledFallback helper for robust cancellation propagation
- Add resolvePreferredTrackIDForExtension for intelligent track ID selection per extension
- Remove ambiguous Auto/Default search provider option, always resolve to concrete provider
- Add tests for shouldAbortCancelledFallback and progress status transitions
This commit is contained in:
zarzet
2026-04-20 13:46:02 +07:00
parent 803cd2de96
commit bb7c86c29e
16 changed files with 540 additions and 189 deletions
+143 -15
View File
@@ -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)
+18
View File
@@ -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 {
+1
View File
@@ -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()
+8 -3
View File
@@ -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)
}
+19
View File
@@ -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)
+21
View File
@@ -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")
+36 -3
View File
@@ -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()
}
}
+59
View File
@@ -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)
}
}
+33 -5
View File
@@ -1711,6 +1711,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
);
}
_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<DownloadQueueState> {
);
final deezerTrackId = await _searchDeezerTrackIdByIsrc(
resolvedIsrc,
lookupContext: '$provider ISRC',
lookupContext:
'${effectiveProvider.isEmpty ? provider : effectiveProvider} ISRC',
itemId: itemId,
);
+81 -4
View File
@@ -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<ExtensionState> {
.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<ExtensionState> {
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<ExtensionState> {
);
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',
);
}
}
+31 -23
View File
@@ -80,6 +80,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
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<AlbumScreen> {
_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<AlbumScreen> {
}
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<AlbumScreen> {
);
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))),
);
+30 -22
View File
@@ -158,22 +158,39 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
_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<ArtistScreen> {
Future<List<Track>> _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',
+7 -52
View File
@@ -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<String>(
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<String>(
value: provider.id,
+38 -18
View File
@@ -52,19 +52,33 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
}
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<PlaylistScreen> {
}
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))),
);
+10 -23
View File
@@ -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),
@@ -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,
),