From 10bc29e3479406651862b4839c75140cdcc81c9c Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 14 Mar 2026 16:07:41 +0700 Subject: [PATCH] feat: add Qobuz and Tidal as built-in metadata search providers with priority-based unified search --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 9 + go_backend/exports.go | 120 +++++------ go_backend/extension_providers.go | 197 +++++++++++++++++- go_backend/extension_providers_test.go | 68 ++++++ go_backend/qobuz.go | 21 ++ go_backend/tidal.go | 44 ++++ ios/Runner/AppDelegate.swift | 14 ++ lib/providers/extension_provider.dart | 8 +- lib/providers/track_provider.dart | 76 +++---- .../metadata_provider_priority_page.dart | 14 ++ lib/services/platform_bridge.dart | 16 ++ 11 files changed, 465 insertions(+), 122 deletions(-) create mode 100644 go_backend/extension_providers_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 51a64a3e..9108e5de 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2878,6 +2878,15 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "searchTracksWithMetadataProviders" -> { + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 20 + val includeExtensions = call.argument("include_extensions") ?: true + val response = withContext(Dispatchers.IO) { + Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions) + } + result.success(response) + } "enrichTrackWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val trackJson = call.argument("track") ?: "{}" diff --git a/go_backend/exports.go b/go_backend/exports.go index 447f7685..674cc01d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1753,79 +1753,54 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath) - // When search_online is true, search for metadata from internet. - // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) + // When search_online is true, search for metadata from internet using the + // configured metadata-provider priority. if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" { GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName) searchQuery := req.TrackName + " " + req.ArtistName found := false - // 1) Try Deezer first (reliable, no credentials needed) - GoLog("[ReEnrich] Trying Deezer search...\n") deezerClient := GetDeezerClient() - { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track") - cancel() - if err == nil && len(deezerResults.Tracks) > 0 { - track := deezerResults.Tracks[0] - GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) - req.SpotifyID = "deezer:" + track.SpotifyID - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - if track.Images != "" { - req.CoverURL = track.Images - } - req.DurationMs = int64(track.DurationMS) - found = true - } else if err != nil { - GoLog("[ReEnrich] Deezer search failed: %v\n", err) + GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") + manager := GetExtensionManager() + tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true) + if searchErr == nil && len(tracks) > 0 { + track := tracks[0] + GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) + if track.SpotifyID != "" { + req.SpotifyID = track.SpotifyID + } else if track.DeezerID != "" { + req.SpotifyID = "deezer:" + track.DeezerID + } else if track.QobuzID != "" { + req.SpotifyID = "qobuz:" + track.QobuzID + } else if track.TidalID != "" { + req.SpotifyID = "tidal:" + track.TidalID + } else { + req.SpotifyID = track.ID } - } - - // 2) Try extension metadata providers (spotify-web etc) if Deezer failed - if !found { - GoLog("[ReEnrich] Trying extension metadata providers...\n") - manager := GetExtensionManager() - extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5) - if extErr == nil && len(extTracks) > 0 { - track := extTracks[0] - GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) - if track.SpotifyID != "" { - req.SpotifyID = track.SpotifyID - } else if track.DeezerID != "" { - req.SpotifyID = "deezer:" + track.DeezerID - } else { - req.SpotifyID = track.ID - } - req.AlbumName = track.AlbumName - req.AlbumArtist = track.AlbumArtist - req.TrackNumber = track.TrackNumber - req.DiscNumber = track.DiscNumber - req.ReleaseDate = track.ReleaseDate - req.ISRC = track.ISRC - coverURL := track.ResolvedCoverURL() - if coverURL != "" { - req.CoverURL = coverURL - } - req.DurationMs = int64(track.DurationMS) - if track.Genre != "" { - req.Genre = track.Genre - } - if track.Label != "" { - req.Label = track.Label - } - if track.Copyright != "" { - req.Copyright = track.Copyright - } - found = true - } else if extErr != nil { - GoLog("[ReEnrich] Extension search failed: %v\n", extErr) + req.AlbumName = track.AlbumName + req.AlbumArtist = track.AlbumArtist + req.TrackNumber = track.TrackNumber + req.DiscNumber = track.DiscNumber + req.ReleaseDate = track.ReleaseDate + req.ISRC = track.ISRC + coverURL := track.ResolvedCoverURL() + if coverURL != "" { + req.CoverURL = coverURL } + req.DurationMs = int64(track.DurationMS) + if track.Genre != "" { + req.Genre = track.Genre + } + if track.Label != "" { + req.Label = track.Label + } + if track.Copyright != "" { + req.Copyright = track.Copyright + } + found = true + } else if searchErr != nil { + GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) } // Try to get extended metadata (genre, label) from Deezer if not already set @@ -2227,6 +2202,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { return string(jsonBytes), nil } +func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) { + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func DownloadWithExtensionsJSON(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 9da7c344..13d759af 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -600,8 +600,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ( return nil, nil } - var allTracks []ExtTrackMetadata + providerByID := make(map[string]*ExtensionProviderWrapper, len(providers)) + orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers)) for _, provider := range providers { + providerByID[provider.extension.ID] = provider + } + for _, providerID := range GetMetadataProviderPriority() { + if provider := providerByID[providerID]; provider != nil { + orderedProviders = append(orderedProviders, provider) + delete(providerByID, providerID) + } + } + if len(providerByID) > 0 { + remainingIDs := make([]string, 0, len(providerByID)) + for providerID := range providerByID { + remainingIDs = append(remainingIDs, providerID) + } + sort.Strings(remainingIDs) + for _, providerID := range remainingIDs { + orderedProviders = append(orderedProviders, providerByID[providerID]) + } + } + + var allTracks []ExtTrackMetadata + for _, provider := range orderedProviders { result, err := provider.SearchTracks(query, limit) if err != nil { GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err) @@ -621,6 +643,8 @@ var providerPriorityMu sync.RWMutex var metadataProviderPriority []string var metadataProviderPriorityMu sync.RWMutex +var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks + func SetProviderPriority(providerIDs []string) { providerPriorityMu.Lock() defer providerPriorityMu.Unlock() @@ -645,7 +669,7 @@ func SetMetadataProviderPriority(providerIDs []string) { metadataProviderPriorityMu.Lock() defer metadataProviderPriorityMu.Unlock() - sanitized := make([]string, 0, len(providerIDs)+1) + sanitized := make([]string, 0, len(providerIDs)+3) seen := map[string]struct{}{} for _, providerID := range providerIDs { providerID = strings.TrimSpace(providerID) @@ -658,8 +682,12 @@ func SetMetadataProviderPriority(providerIDs []string) { seen[providerID] = struct{}{} sanitized = append(sanitized, providerID) } - if _, exists := seen["deezer"]; !exists { - sanitized = append([]string{"deezer"}, sanitized...) + for _, providerID := range []string{"deezer", "qobuz", "tidal"} { + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) } metadataProviderPriority = sanitized @@ -671,7 +699,7 @@ func GetMetadataProviderPriority() []string { defer metadataProviderPriorityMu.RUnlock() if len(metadataProviderPriority) == 0 { - return []string{"deezer"} + return []string{"deezer", "qobuz", "tidal"} } result := make([]string, len(metadataProviderPriority)) @@ -688,6 +716,165 @@ func isBuiltInProvider(providerID string) bool { } } +func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata { + deezerID := "" + tidalID := "" + qobuzID := "" + prefixedID := strings.TrimSpace(track.SpotifyID) + + switch providerID { + case "deezer": + deezerID = strings.TrimPrefix(prefixedID, "deezer:") + case "tidal": + tidalID = strings.TrimPrefix(prefixedID, "tidal:") + case "qobuz": + qobuzID = strings.TrimPrefix(prefixedID, "qobuz:") + } + + return ExtTrackMetadata{ + ID: prefixedID, + Name: track.Name, + Artists: track.Artists, + AlbumName: track.AlbumName, + AlbumArtist: track.AlbumArtist, + DurationMS: track.DurationMS, + CoverURL: track.Images, + Images: track.Images, + ReleaseDate: track.ReleaseDate, + TrackNumber: track.TrackNumber, + DiscNumber: track.DiscNumber, + ISRC: track.ISRC, + ProviderID: providerID, + SpotifyID: prefixedID, + DeezerID: deezerID, + TidalID: tidalID, + QobuzID: qobuzID, + AlbumType: track.AlbumType, + } +} + +func metadataTrackDedupKey(track ExtTrackMetadata) string { + if isrc := strings.TrimSpace(track.ISRC); isrc != "" { + return "isrc:" + strings.ToUpper(isrc) + } + if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" { + return "spotify:" + spotifyID + } + if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" { + return providerID + ":" + strings.TrimSpace(track.ID) + } + return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists) +} + +func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + switch providerID { + case "deezer": + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track") + if err != nil { + return nil, err + } + + tracks := make([]ExtTrackMetadata, 0, len(results.Tracks)) + for _, track := range results.Tracks { + tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer")) + } + return tracks, nil + case "qobuz": + return NewQobuzDownloader().SearchTracks(query, limit) + case "tidal": + return NewTidalDownloader().SearchTracks(query, limit) + default: + return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID) + } +} + +func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { + priority := GetMetadataProviderPriority() + if limit <= 0 { + limit = 20 + } + + extensionProviders := make(map[string]*ExtensionProviderWrapper) + if includeExtensions { + for _, provider := range m.GetMetadataProviders() { + extensionProviders[provider.extension.ID] = provider + } + } + + orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders)) + seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders)) + for _, providerID := range priority { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + orderedProviderIDs = append(orderedProviderIDs, providerID) + seenProviderIDs[providerID] = struct{}{} + } + if includeExtensions { + remainingIDs := make([]string, 0, len(extensionProviders)) + for providerID := range extensionProviders { + if _, exists := seenProviderIDs[providerID]; exists { + continue + } + remainingIDs = append(remainingIDs, providerID) + } + sort.Strings(remainingIDs) + orderedProviderIDs = append(orderedProviderIDs, remainingIDs...) + } + + tracks := make([]ExtTrackMetadata, 0, limit) + seenTracks := make(map[string]struct{}) + for _, providerID := range orderedProviderIDs { + var ( + providerTracks []ExtTrackMetadata + err error + ) + + if isBuiltInProvider(providerID) { + providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit) + } else { + if !includeExtensions { + continue + } + provider := extensionProviders[providerID] + if provider == nil { + continue + } + var result *ExtSearchResult + result, err = provider.SearchTracks(query, limit) + if result != nil { + providerTracks = result.Tracks + } + } + + if err != nil { + GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err) + continue + } + + for _, track := range providerTracks { + key := metadataTrackDedupKey(track) + if key == "" { + continue + } + if _, exists := seenTracks[key]; exists { + continue + } + seenTracks[key] = struct{}{} + tracks = append(tracks, track) + if len(tracks) >= limit { + return tracks, nil + } + } + } + + return tracks, nil +} + func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := GetExtensionManager() diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go new file mode 100644 index 00000000..3112de44 --- /dev/null +++ b/go_backend/extension_providers_test.go @@ -0,0 +1,68 @@ +package gobackend + +import "testing" + +func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { + original := GetMetadataProviderPriority() + defer SetMetadataProviderPriority(original) + + SetMetadataProviderPriority([]string{"tidal"}) + got := GetMetadataProviderPriority() + want := []string{"tidal", "deezer", "qobuz"} + if len(got) != len(want) { + t.Fatalf("unexpected priority length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want) + } + } +} + +func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) { + originalPriority := GetMetadataProviderPriority() + originalSearch := searchBuiltInMetadataTracksFunc + defer func() { + SetMetadataProviderPriority(originalPriority) + searchBuiltInMetadataTracksFunc = originalSearch + }() + + SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"}) + + var calls []string + searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + calls = append(calls, providerID) + switch providerID { + 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"}, + }, nil + case "deezer": + return []ExtTrackMetadata{ + {ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"}, + }, nil + default: + return nil, nil + } + } + + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false) + if err != nil { + t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err) + } + if len(tracks) != 3 { + t.Fatalf("unexpected track count: got %d want 3", len(tracks)) + } + if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" { + t.Fatalf("unexpected track provider order: %+v", tracks) + } + if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" { + t.Fatalf("unexpected provider call order: %v", calls) + } +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index d51e45de..788f816f 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1278,6 +1278,27 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) } +func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty qobuz search query") + } + if limit <= 0 { + limit = 20 + } + + tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit) + if err != nil { + return nil, err + } + + results := make([]ExtTrackMetadata, 0, len(tracks)) + for i := range tracks { + results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz")) + } + return results, nil +} + func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { queries := []string{} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 42295eb1..8a68826d 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -208,6 +208,13 @@ type tidalPublicPlaylistItemsPage struct { } `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{ @@ -590,6 +597,30 @@ func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit 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"` @@ -738,6 +769,19 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } +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) GetTrackMetadata(resourceID string) (*TrackResponse, error) { track, err := t.getPublicTrack(resourceID) if err != nil { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d72d27f2..5bdc9792 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -623,6 +623,20 @@ import Gobackend // Import Go framework let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error) if let error = error { throw error } return response + + case "searchTracksWithMetadataProviders": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let limit = args["limit"] as? Int ?? 20 + let includeExtensions = args["include_extensions"] as? Bool ?? true + let response = GobackendSearchTracksWithMetadataProvidersJSON( + query, + Int(limit), + includeExtensions, + &error + ) + if let error = error { throw error } + return response case "enrichTrackWithExtension": let args = call.arguments as! [String: Any] diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 58d14ee5..19eafa75 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -892,7 +892,7 @@ class ExtensionNotifier extends Notifier { } List getAllMetadataProviders() { - final providers = ['deezer']; + final providers = ['deezer', 'qobuz', 'tidal']; for (final ext in state.extensions) { if (ext.enabled && ext.hasMetadataProvider) { providers.add(ext.id); @@ -911,8 +911,10 @@ class ExtensionNotifier extends Notifier { } } - if (!result.contains('deezer')) { - result.insert(0, 'deezer'); + for (final provider in const ['deezer', 'qobuz', 'tidal']) { + if (!result.contains(provider)) { + result.add(provider); + } } return result; diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 30511899..8c1dd6a8 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -637,41 +637,31 @@ class TrackNotifier extends Notifier { final hasActiveMetadataExtensions = extensionState.extensions.any( (e) => e.enabled && e.hasMetadataProvider, ); - final searchProvider = settings.searchProvider; - final useExtensions = - settings.useExtensionProviders && - hasActiveMetadataExtensions && - searchProvider != null && - searchProvider.isNotEmpty; - - const source = 'deezer'; + final includeExtensions = + settings.useExtensionProviders && hasActiveMetadataExtensions; _log.i( - 'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter', + 'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', ); Map results; - List extensionTracks = []; + List> metadataTrackResults = []; - if (useExtensions) { - try { - _log.d('Calling extension search API...'); - final extResults = await PlatformBridge.searchTracksWithExtensions( - query, - limit: 20, - ); - _log.i('Extensions returned ${extResults.length} tracks'); - - for (final t in extResults) { - try { - extensionTracks.add(_parseSearchTrack(t)); - } catch (e) { - _log.e('Failed to parse extension track: $e', e); - } - } - } catch (e) { - _log.w('Extension search failed, falling back to Deezer: $e'); - } + try { + _log.d('Calling metadata provider search API...'); + metadataTrackResults = + await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 20, + includeExtensions: includeExtensions, + ); + _log.i( + 'Metadata providers returned ${metadataTrackResults.length} tracks', + ); + } catch (e) { + _log.w( + 'Metadata provider search failed, falling back to Deezer tracks: $e', + ); } _log.d('Calling Deezer search API...'); @@ -693,32 +683,20 @@ class TrackNotifier extends Notifier { final trackList = results['tracks'] as List? ?? []; final artistList = results['artists'] as List? ?? []; final albumList = results['albums'] as List? ?? []; + final trackSearchResults = metadataTrackResults.isNotEmpty + ? metadataTrackResults + : trackList.whereType>().toList(); _log.d( - 'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums', + 'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums', ); final tracks = []; - tracks.addAll(extensionTracks); - - final existingIsrcs = extensionTracks - .where((t) => t.isrc != null && t.isrc!.isNotEmpty) - .map((t) => t.isrc!) - .toSet(); - - for (int i = 0; i < trackList.length; i++) { - final t = trackList[i]; + for (int i = 0; i < trackSearchResults.length; i++) { + final t = trackSearchResults[i]; try { - if (t is Map) { - final track = _parseSearchTrack(t); - if (track.isrc != null && existingIsrcs.contains(track.isrc)) { - continue; - } - tracks.add(track); - } else { - _log.w('Track[$i] is not a Map: ${t.runtimeType}'); - } + tracks.add(_parseSearchTrack(t)); } catch (e) { _log.e('Failed to parse track[$i]: $e', e); } @@ -768,7 +746,7 @@ class TrackNotifier extends Notifier { } _log.i( - 'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully', + 'Search complete: ${tracks.length} tracks, ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully', ); state = TrackState( diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 3c614784..ba66489c 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -228,6 +228,20 @@ class _MetadataProviderItem extends StatelessWidget { description: context.l10n.metadataNoRateLimits, isBuiltIn: true, ); + case 'qobuz': + return _MetadataProviderInfo( + name: 'Qobuz', + icon: Icons.library_music, + description: context.l10n.providerBuiltIn, + isBuiltIn: true, + ); + case 'tidal': + return _MetadataProviderInfo( + name: 'Tidal', + icon: Icons.music_note, + description: context.l10n.providerBuiltIn, + isBuiltIn: true, + ); default: return _MetadataProviderInfo( name: provider, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f3601165..d5e5c46f 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -816,6 +816,22 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + static Future>> searchTracksWithMetadataProviders( + String query, { + int limit = 20, + bool includeExtensions = true, + }) async { + _log.d( + 'searchTracksWithMetadataProviders: "$query", includeExtensions=$includeExtensions', + ); + final result = await _channel.invokeMethod( + 'searchTracksWithMetadataProviders', + {'query': query, 'limit': limit, 'include_extensions': includeExtensions}, + ); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions');