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 78229d30..5fdb3ebb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2864,23 +2864,20 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - "searchTidalAll" -> { + "searchProviderAll" -> { + val providerId = call.argument("provider_id") ?: "" val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 val artistLimit = call.argument("artist_limit") ?: 2 val filter = call.argument("filter") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + Gobackend.searchProviderAllJSON(providerId, query, trackLimit.toLong(), artistLimit.toLong(), filter) } result.success(response) } - "searchQobuzAll" -> { - val query = call.argument("query") ?: "" - val trackLimit = call.argument("track_limit") ?: 15 - val artistLimit = call.argument("artist_limit") ?: 2 - val filter = call.argument("filter") ?: "" + "getBuiltInProviders" -> { val response = withContext(Dispatchers.IO) { - Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + Gobackend.getBuiltInProvidersJSON() } result.success(response) } @@ -2892,14 +2889,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getDeezerMetadata" -> { - val resourceType = call.argument("resource_type") ?: "" - val resourceId = call.argument("resource_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getDeezerMetadata(resourceType, resourceId) - } - result.success(response) - } "getQobuzMetadata" -> { val resourceType = call.argument("resource_type") ?: "" val resourceId = call.argument("resource_id") ?: "" @@ -2916,24 +2905,19 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "parseDeezerUrl" -> { - val url = call.argument("url") ?: "" + "getProviderMetadata" -> { + val providerId = call.argument("provider_id") ?: "" + val resourceType = call.argument("resource_type") ?: "" + val resourceId = call.argument("resource_id") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.parseDeezerURLExport(url) + Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId) } result.success(response) } - "parseQobuzUrl" -> { + "parseProviderUrl" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.parseQobuzURLExport(url) - } - result.success(response) - } - "parseTidalUrl" -> { - val url = call.argument("url") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.parseTidalURLExport(url) + Gobackend.parseProviderURLJSON(url) } result.success(response) } @@ -3291,30 +3275,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getAlbumWithExtension" -> { - val extensionId = call.argument("extension_id") ?: "" - val albumId = call.argument("album_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getAlbumWithExtensionJSON(extensionId, albumId) - } - result.success(response) - } - "getPlaylistWithExtension" -> { - val extensionId = call.argument("extension_id") ?: "" - val playlistId = call.argument("playlist_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId) - } - result.success(response) - } - "getArtistWithExtension" -> { - val extensionId = call.argument("extension_id") ?: "" - val artistId = call.argument("artist_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getArtistWithExtensionJSON(extensionId, artistId) - } - result.success(response) - } "runPostProcessing" -> { val filePath = call.argument("file_path") ?: "" val metadataJson = call.argument("metadata") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index 2a62c848..068aadc3 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1051,51 +1051,12 @@ func DownloadTrack(requestJSON string) (string, error) { return errorResponse("Download cancelled") } - var result DownloadResult - var err error - - switch req.Service { - case "tidal": - tidalResult, tidalErr := downloadFromTidal(req) - if tidalErr == nil { - result = DownloadResult{ - FilePath: tidalResult.FilePath, - BitDepth: tidalResult.BitDepth, - SampleRate: tidalResult.SampleRate, - Title: tidalResult.Title, - Artist: tidalResult.Artist, - Album: tidalResult.Album, - ReleaseDate: tidalResult.ReleaseDate, - TrackNumber: tidalResult.TrackNumber, - DiscNumber: tidalResult.DiscNumber, - ISRC: tidalResult.ISRC, - LyricsLRC: tidalResult.LyricsLRC, - } - } - err = tidalErr - case "qobuz": - qobuzResult, qobuzErr := downloadFromQobuz(req) - if qobuzErr == nil { - result = DownloadResult{ - FilePath: qobuzResult.FilePath, - BitDepth: qobuzResult.BitDepth, - SampleRate: qobuzResult.SampleRate, - Title: qobuzResult.Title, - Artist: qobuzResult.Artist, - Album: qobuzResult.Album, - ReleaseDate: qobuzResult.ReleaseDate, - TrackNumber: qobuzResult.TrackNumber, - DiscNumber: qobuzResult.DiscNumber, - ISRC: qobuzResult.ISRC, - CoverURL: qobuzResult.CoverURL, - LyricsLRC: qobuzResult.LyricsLRC, - } - } - err = qobuzErr - default: + if !isBuiltInDownloadProvider(req.Service) { return errorResponse("Unknown service: " + req.Service) } + result, err := downloadWithBuiltInProvider(req.Service, req) + if err != nil { return errorResponse(err.Error()) } @@ -1227,51 +1188,9 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Trying service: %s\n", service) req.Service = service - var result DownloadResult - var err error - - switch service { - case "tidal": - tidalResult, tidalErr := downloadFromTidal(req) - if tidalErr == nil { - result = DownloadResult{ - FilePath: tidalResult.FilePath, - BitDepth: tidalResult.BitDepth, - SampleRate: tidalResult.SampleRate, - Title: tidalResult.Title, - Artist: tidalResult.Artist, - Album: tidalResult.Album, - ReleaseDate: tidalResult.ReleaseDate, - TrackNumber: tidalResult.TrackNumber, - DiscNumber: tidalResult.DiscNumber, - ISRC: tidalResult.ISRC, - LyricsLRC: tidalResult.LyricsLRC, - } - } else if !errors.Is(tidalErr, ErrDownloadCancelled) { - GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr) - } - err = tidalErr - case "qobuz": - qobuzResult, qobuzErr := downloadFromQobuz(req) - if qobuzErr == nil { - result = DownloadResult{ - FilePath: qobuzResult.FilePath, - BitDepth: qobuzResult.BitDepth, - SampleRate: qobuzResult.SampleRate, - Title: qobuzResult.Title, - Artist: qobuzResult.Artist, - Album: qobuzResult.Album, - ReleaseDate: qobuzResult.ReleaseDate, - TrackNumber: qobuzResult.TrackNumber, - DiscNumber: qobuzResult.DiscNumber, - ISRC: qobuzResult.ISRC, - CoverURL: qobuzResult.CoverURL, - LyricsLRC: qobuzResult.LyricsLRC, - } - } else if !errors.Is(qobuzErr, ErrDownloadCancelled) { - GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) - } - err = qobuzErr + result, err := downloadWithBuiltInProvider(service, req) + if err != nil && !errors.Is(err, ErrDownloadCancelled) { + GoLog("[DownloadWithFallback] %s error: %v\n", service, err) } if err != nil && errors.Is(err, ErrDownloadCancelled) { @@ -2058,6 +1977,28 @@ func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (s return string(jsonBytes), nil } +func SearchProviderAllJSON( + providerID, + query string, + trackLimit, + artistLimit int, + filter string, +) (string, error) { + normalizedProviderID := strings.ToLower(strings.TrimSpace(providerID)) + if !isBuiltInSearchProvider(normalizedProviderID) { + return "", fmt.Errorf("unsupported search provider: %s", providerID) + } + return searchBuiltInProviderAll(normalizedProviderID, query, trackLimit, artistLimit, filter) +} + +func GetBuiltInProvidersJSON() (string, error) { + jsonBytes, err := json.Marshal(getBuiltInProviderSpecs()) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() @@ -2141,6 +2082,208 @@ func GetQobuzMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } +func normalizeExtensionTrackMetadataMap( + track ExtTrackMetadata, + fallbackCover string, + fallbackTrackNumber int, +) map[string]interface{} { + coverURL := track.ResolvedCoverURL() + if coverURL == "" { + coverURL = fallbackCover + } + + trackNum := track.TrackNumber + if trackNum == 0 && fallbackTrackNumber > 0 { + trackNum = fallbackTrackNumber + } + + return map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": coverURL, + "cover_url": coverURL, + "release_date": track.ReleaseDate, + "track_number": trackNum, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "spotify_id": track.SpotifyID, + "composer": track.Composer, + } +} + +func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interface{} { + if album == nil { + return map[string]interface{}{} + } + + return map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "artist_id": album.ArtistID, + "images": album.CoverURL, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "provider_id": album.ProviderID, + } +} + +func normalizeExtensionArtistAlbumMap(album ExtAlbumMetadata) map[string]interface{} { + return map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "provider_id": album.ProviderID, + } +} + +func getExtensionProviderMetadataResponse( + providerID, + resourceType, + resourceID string, +) (map[string]interface{}, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(providerID) + if err != nil { + return nil, err + } + + if !ext.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID) + } + if !ext.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", providerID) + } + + provider := newExtensionProviderWrapper(ext) + + switch resourceType { + case "track": + track, err := provider.GetTrack(resourceID) + if err != nil { + return nil, err + } + if track == nil { + return nil, fmt.Errorf("track not found") + } + return map[string]interface{}{ + "track": normalizeExtensionTrackMetadataMap(*track, "", 0), + }, nil + case "album": + album, err := provider.GetAlbum(resourceID) + if err != nil { + return nil, err + } + if album == nil { + return nil, fmt.Errorf("album not found") + } + + tracks := make([]map[string]interface{}, len(album.Tracks)) + for i, track := range album.Tracks { + tracks[i] = normalizeExtensionTrackMetadataMap(track, album.CoverURL, i+1) + } + + return map[string]interface{}{ + "album_info": normalizeExtensionAlbumInfoMap(album), + "track_list": tracks, + }, nil + case "playlist": + playlist, err := provider.GetPlaylist(resourceID) + if err != nil { + return nil, err + } + if playlist == nil { + return nil, fmt.Errorf("playlist not found") + } + + tracks := make([]map[string]interface{}, len(playlist.Tracks)) + for i, track := range playlist.Tracks { + tracks[i] = normalizeExtensionTrackMetadataMap(track, playlist.CoverURL, i+1) + } + + return map[string]interface{}{ + "playlist_info": map[string]interface{}{ + "id": playlist.ID, + "name": playlist.Name, + "images": playlist.CoverURL, + "cover_url": playlist.CoverURL, + "provider_id": playlist.ProviderID, + "owner": map[string]interface{}{ + "name": playlist.Artists, + "images": playlist.CoverURL, + }, + }, + "track_list": tracks, + }, nil + case "artist": + artist, err := provider.GetArtist(resourceID) + if err != nil { + return nil, err + } + if artist == nil { + return nil, fmt.Errorf("artist not found") + } + + albums := make([]map[string]interface{}, len(artist.Albums)) + for i, album := range artist.Albums { + albums[i] = normalizeExtensionArtistAlbumMap(album) + } + + response := map[string]interface{}{ + "artist_info": map[string]interface{}{ + "id": artist.ID, + "name": artist.Name, + "images": tidalFirstNonEmpty(artist.HeaderImage, artist.ImageURL), + "cover_url": artist.ImageURL, + "header_image": artist.HeaderImage, + "provider_id": artist.ProviderID, + }, + "albums": albums, + } + + if len(artist.Releases) > 0 { + releases := make([]map[string]interface{}, len(artist.Releases)) + for i, release := range artist.Releases { + releases[i] = normalizeExtensionArtistAlbumMap(release) + } + response["releases"] = releases + } + + if artist.Listeners > 0 { + artistInfo := response["artist_info"].(map[string]interface{}) + artistInfo["listeners"] = artist.Listeners + } + + if len(artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(artist.TopTracks)) + for i, track := range artist.TopTracks { + topTracks[i] = normalizeExtensionTrackMetadataMap(track, artist.ImageURL, i+1) + } + response["top_tracks"] = topTracks + } + + return response, nil + default: + return nil, fmt.Errorf("unsupported provider resource type: %s", resourceType) + } +} + func GetTidalMetadata(resourceType, resourceID string) (string, error) { downloader := NewTidalDownloader() @@ -2171,6 +2314,34 @@ func GetTidalMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } +func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) { + trimmedProviderID := strings.TrimSpace(providerID) + if trimmedProviderID == "" { + return "", fmt.Errorf("empty provider ID") + } + + normalizedProviderID := strings.ToLower(trimmedProviderID) + if isBuiltInMetadataProvider(normalizedProviderID) { + return getBuiltInProviderMetadata(normalizedProviderID, resourceType, resourceID) + } + + switch normalizedProviderID { + case "deezer": + return GetDeezerMetadata(resourceType, resourceID) + default: + response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } +} + func ParseDeezerURLExport(url string) (string, error) { resourceType, resourceID, err := parseDeezerURL(url) if err != nil { @@ -2228,6 +2399,38 @@ func ParseTidalURLExport(url string) (string, error) { return string(jsonBytes), nil } +func ParseProviderURLJSON(url string) (string, error) { + parsers := []struct { + providerID string + parse func(string) (string, string, error) + }{ + {providerID: "deezer", parse: parseDeezerURL}, + {providerID: "qobuz", parse: parseQobuzURL}, + {providerID: "tidal", parse: parseTidalURL}, + } + + for _, parser := range parsers { + resourceType, resourceID, err := parser.parse(url) + if err != nil { + continue + } + + result := map[string]string{ + "provider_id": parser.providerID, + "type": resourceType, + "id": resourceID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + return "", fmt.Errorf("unsupported provider URL") +} + func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) { client := NewSongLinkClient() availability, err := client.CheckAvailabilityFromURL(tidalURL) @@ -3599,288 +3802,6 @@ func FindURLHandlerJSON(url string) string { return handler.extension.ID } -func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Manifest.IsMetadataProvider() { - return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) - } - if !ext.Enabled { - return "", fmt.Errorf("extension '%s' is disabled", extensionID) - } - - provider := newExtensionProviderWrapper(ext) - album, err := provider.GetAlbum(albumID) - if err != nil { - return "", err - } - - if album == nil { - return "", fmt.Errorf("album not found") - } - - tracks := make([]map[string]interface{}, len(album.Tracks)) - for i, track := range album.Tracks { - trackCover := track.ResolvedCoverURL() - if trackCover == "" { - trackCover = album.CoverURL - } - trackNum := track.TrackNumber - if trackNum == 0 { - trackNum = i + 1 - } - tracks[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "cover_url": trackCover, - "release_date": track.ReleaseDate, - "track_number": trackNum, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "composer": track.Composer, - } - } - - response := map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "artists": album.Artists, - "artist_id": album.ArtistID, - "cover_url": album.CoverURL, - "release_date": album.ReleaseDate, - "total_tracks": album.TotalTracks, - "album_type": album.AlbumType, - "tracks": tracks, - "provider_id": album.ProviderID, - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Manifest.IsMetadataProvider() { - return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) - } - if !ext.Enabled { - return "", fmt.Errorf("extension '%s' is disabled", extensionID) - } - - vm, err := ext.lockReadyVM() - if err != nil { - return "", err - } - defer ext.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') { - return extension.getPlaylist(%q); - } - if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { - return extension.getAlbum(%q); - } - return null; - })() - `, playlistID, playlistID) - - result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout) - if err != nil { - return "", fmt.Errorf("getPlaylist failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return "", fmt.Errorf("playlist not found") - } - - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return "", fmt.Errorf("failed to marshal result: %w", err) - } - - var album ExtAlbumMetadata - if err := json.Unmarshal(jsonBytes, &album); err != nil { - return "", fmt.Errorf("failed to parse playlist: %w", err) - } - album.ProviderID = ext.ID - for i := range album.Tracks { - album.Tracks[i].ProviderID = ext.ID - } - - tracks := make([]map[string]interface{}, len(album.Tracks)) - for i, track := range album.Tracks { - trackCover := track.ResolvedCoverURL() - if trackCover == "" { - trackCover = album.CoverURL - } - tracks[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "cover_url": trackCover, - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "composer": track.Composer, - } - } - - response := map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "owner": album.Artists, - "cover_url": album.CoverURL, - "total_tracks": album.TotalTracks, - "tracks": tracks, - "provider_id": album.ProviderID, - } - - jsonBytes, err = json.Marshal(response) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Manifest.IsMetadataProvider() { - return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID) - } - - provider := newExtensionProviderWrapper(ext) - artist, err := provider.GetArtist(artistID) - if err != nil { - return "", err - } - - if artist == nil { - return "", fmt.Errorf("artist not found") - } - - albums := make([]map[string]interface{}, len(artist.Albums)) - for i, album := range artist.Albums { - albums[i] = map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "artists": album.Artists, - "cover_url": album.CoverURL, - "release_date": album.ReleaseDate, - "total_tracks": album.TotalTracks, - "album_type": album.AlbumType, - "provider_id": album.ProviderID, - } - } - - response := map[string]interface{}{ - "id": artist.ID, - "name": artist.Name, - "cover_url": artist.ImageURL, - "albums": albums, - "provider_id": artist.ProviderID, - } - - if len(artist.Releases) > 0 { - releases := make([]map[string]interface{}, len(artist.Releases)) - for i, release := range artist.Releases { - releaseType := release.AlbumType - if releaseType == "" { - releaseType = "album" - } - releases[i] = map[string]interface{}{ - "id": release.ID, - "name": release.Name, - "artists": release.Artists, - "cover_url": release.CoverURL, - "release_date": release.ReleaseDate, - "total_tracks": release.TotalTracks, - "album_type": releaseType, - "provider_id": release.ProviderID, - } - } - response["releases"] = releases - } - - if artist.HeaderImage != "" { - response["header_image"] = artist.HeaderImage - } - - if artist.Listeners > 0 { - response["listeners"] = artist.Listeners - } - - if len(artist.TopTracks) > 0 { - topTracks := make([]map[string]interface{}, len(artist.TopTracks)) - for i, track := range artist.TopTracks { - topTracks[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "images": track.ResolvedCoverURL(), - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "spotify_id": track.SpotifyID, - "composer": track.Composer, - } - } - response["top_tracks"] = topTracks - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func GetURLHandlersJSON() (string, error) { manager := getExtensionManager() handlers := manager.GetURLHandlers() diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index d832215c..affe9a4c 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -96,6 +96,136 @@ type ExtDownloadURLResult struct { SampleRate int `json:"sample_rate,omitempty"` } +type builtInProviderSpec struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + SupportsMetadata bool `json:"supports_metadata"` + SupportsDownload bool `json:"supports_download"` + SupportsSearch bool `json:"supports_search"` + GetMetadata func(resourceType, resourceID string) (string, error) `json:"-"` + SearchAll func(query string, trackLimit, artistLimit int, filter string) (string, error) `json:"-"` + SearchTracks func(query string, limit int) ([]ExtTrackMetadata, error) `json:"-"` + Download func(req DownloadRequest) (DownloadResult, error) `json:"-"` +} + +var builtInProviderRegistry = []builtInProviderSpec{ + { + ID: "tidal", + DisplayName: "Tidal", + SupportsMetadata: true, + SupportsDownload: true, + SupportsSearch: true, + GetMetadata: GetTidalMetadata, + SearchAll: SearchTidalAll, + SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) { + return NewTidalDownloader().SearchTracks(query, limit) + }, + Download: downloadWithBuiltInTidal, + }, + { + ID: "qobuz", + DisplayName: "Qobuz", + SupportsMetadata: true, + SupportsDownload: true, + SupportsSearch: true, + GetMetadata: GetQobuzMetadata, + SearchAll: SearchQobuzAll, + SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) { + return NewQobuzDownloader().SearchTracks(query, limit) + }, + Download: downloadWithBuiltInQobuz, + }, +} + +func getBuiltInProviderSpecs() []builtInProviderSpec { + specs := make([]builtInProviderSpec, len(builtInProviderRegistry)) + copy(specs, builtInProviderRegistry) + return specs +} + +func getBuiltInProviderSpec(providerID string) (builtInProviderSpec, bool) { + normalized := strings.ToLower(strings.TrimSpace(providerID)) + for _, spec := range builtInProviderRegistry { + if spec.ID == normalized { + return spec, true + } + } + return builtInProviderSpec{}, false +} + +func getBuiltInProviderMetadata(providerID, resourceType, resourceID string) (string, error) { + spec, ok := getBuiltInProviderSpec(providerID) + if !ok || !spec.SupportsMetadata || spec.GetMetadata == nil { + return "", fmt.Errorf("unsupported built-in metadata provider: %s", providerID) + } + return spec.GetMetadata(resourceType, resourceID) +} + +func searchBuiltInProviderAll(providerID, query string, trackLimit, artistLimit int, filter string) (string, error) { + spec, ok := getBuiltInProviderSpec(providerID) + if !ok || !spec.SupportsSearch || spec.SearchAll == nil { + return "", fmt.Errorf("unsupported search provider: %s", providerID) + } + return spec.SearchAll(query, trackLimit, artistLimit, filter) +} + +func searchBuiltInProviderTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + spec, ok := getBuiltInProviderSpec(providerID) + if !ok || !spec.SupportsMetadata || spec.SearchTracks == nil { + return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID) + } + return spec.SearchTracks(query, limit) +} + +func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (DownloadResult, error) { + spec, ok := getBuiltInProviderSpec(providerID) + if !ok || !spec.SupportsDownload || spec.Download == nil { + return DownloadResult{}, fmt.Errorf("unknown built-in provider: %s", providerID) + } + return spec.Download(req) +} + +func downloadWithBuiltInTidal(req DownloadRequest) (DownloadResult, error) { + result, err := downloadFromTidal(req) + if err != nil { + return DownloadResult{}, err + } + return DownloadResult{ + FilePath: result.FilePath, + BitDepth: result.BitDepth, + SampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, + LyricsLRC: result.LyricsLRC, + }, nil +} + +func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) { + result, err := downloadFromQobuz(req) + if err != nil { + return DownloadResult{}, err + } + return DownloadResult{ + FilePath: result.FilePath, + BitDepth: result.BitDepth, + SampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, + CoverURL: result.CoverURL, + LyricsLRC: result.LyricsLRC, + }, nil +} + func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { return availability != nil && availability.SkipFallback } @@ -435,6 +565,61 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, return &album, nil } +func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + if err := p.lockReadyVM(); err != nil { + return nil, err + } + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') { + return extension.getPlaylist(%q); + } + if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { + return extension.getAlbum(%q); + } + return null; + })() + `, playlistID, playlistID) + + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getPlaylist failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getPlaylist returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var playlist ExtAlbumMetadata + if err := json.Unmarshal(jsonBytes, &playlist); err != nil { + return nil, fmt.Errorf("failed to parse playlist: %w", err) + } + + playlist.ProviderID = p.extension.ID + for i := range playlist.Tracks { + playlist.Tracks[i].ProviderID = p.extension.ID + } + return &playlist, nil +} + func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) @@ -919,7 +1104,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz"} + return []string{} } result := make([]string, len(providerPriority)) @@ -928,7 +1113,7 @@ func GetProviderPriority() []string { } func sanitizeDownloadProviderPriority(providerIDs []string) []string { - sanitized := make([]string, 0, len(providerIDs)+2) + sanitized := make([]string, 0, len(providerIDs)) seen := map[string]struct{}{} for _, providerID := range providerIDs { @@ -953,14 +1138,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string { sanitized = append(sanitized, providerID) } - for _, providerID := range []string{"tidal", "qobuz"} { - if _, exists := seen[providerID]; exists { - continue - } - seen[providerID] = struct{}{} - sanitized = append(sanitized, providerID) - } - return sanitized } @@ -1027,7 +1204,7 @@ func SetMetadataProviderPriority(providerIDs []string) { metadataProviderPriorityMu.Lock() defer metadataProviderPriorityMu.Unlock() - sanitized := make([]string, 0, len(providerIDs)+2) + sanitized := make([]string, 0, len(providerIDs)) seen := map[string]struct{}{} for _, providerID := range providerIDs { providerID = strings.TrimSpace(providerID) @@ -1040,14 +1217,6 @@ func SetMetadataProviderPriority(providerIDs []string) { seen[providerID] = struct{}{} sanitized = append(sanitized, providerID) } - for _, providerID := range []string{"qobuz", "tidal"} { - if _, exists := seen[providerID]; exists { - continue - } - seen[providerID] = struct{}{} - sanitized = append(sanitized, providerID) - } - metadataProviderPriority = sanitized GoLog("[Extension] Metadata provider priority set: %v\n", sanitized) } @@ -1057,7 +1226,7 @@ func GetMetadataProviderPriority() []string { defer metadataProviderPriorityMu.RUnlock() if len(metadataProviderPriority) == 0 { - return []string{"qobuz", "tidal"} + return []string{} } result := make([]string, len(metadataProviderPriority)) @@ -1066,21 +1235,23 @@ func GetMetadataProviderPriority() []string { } func isBuiltInProvider(providerID string) bool { - switch providerID { - case "tidal", "qobuz": - return true - default: - return false - } + _, ok := getBuiltInProviderSpec(providerID) + return ok +} + +func isBuiltInMetadataProvider(providerID string) bool { + spec, ok := getBuiltInProviderSpec(providerID) + return ok && spec.SupportsMetadata +} + +func isBuiltInSearchProvider(providerID string) bool { + spec, ok := getBuiltInProviderSpec(providerID) + return ok && spec.SupportsSearch } func isBuiltInDownloadProvider(providerID string) bool { - switch providerID { - case "tidal", "qobuz": - return true - default: - return false - } + spec, ok := getBuiltInProviderSpec(providerID) + return ok && spec.SupportsDownload } func normalizeQualityForBuiltIn(quality string) string { @@ -1150,14 +1321,7 @@ func metadataTrackDedupKey(track ExtTrackMetadata) string { } func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) { - switch providerID { - 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) - } + return searchBuiltInProviderTracks(providerID, query, limit) } func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { @@ -1958,49 +2122,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) { req.Service = providerID - var result DownloadResult - var err error - - switch providerID { - case "tidal": - tidalResult, tidalErr := downloadFromTidal(req) - if tidalErr == nil { - result = DownloadResult{ - FilePath: tidalResult.FilePath, - BitDepth: tidalResult.BitDepth, - SampleRate: tidalResult.SampleRate, - Title: tidalResult.Title, - Artist: tidalResult.Artist, - Album: tidalResult.Album, - ReleaseDate: tidalResult.ReleaseDate, - TrackNumber: tidalResult.TrackNumber, - DiscNumber: tidalResult.DiscNumber, - ISRC: tidalResult.ISRC, - } - } - err = tidalErr - case "qobuz": - qobuzResult, qobuzErr := downloadFromQobuz(req) - if qobuzErr == nil { - result = DownloadResult{ - FilePath: qobuzResult.FilePath, - BitDepth: qobuzResult.BitDepth, - SampleRate: qobuzResult.SampleRate, - Title: qobuzResult.Title, - Artist: qobuzResult.Artist, - Album: qobuzResult.Album, - ReleaseDate: qobuzResult.ReleaseDate, - TrackNumber: qobuzResult.TrackNumber, - DiscNumber: qobuzResult.DiscNumber, - ISRC: qobuzResult.ISRC, - CoverURL: qobuzResult.CoverURL, - } - } - err = qobuzErr - default: - return nil, fmt.Errorf("unknown built-in provider: %s", providerID) - } - + result, err := downloadWithBuiltInProvider(providerID, req) if err != nil { return nil, err } diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index cdc1621d..5b3a4c00 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -7,13 +7,13 @@ import ( "testing" ) -func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) { +func TestSetMetadataProviderPriorityPreservesExplicitProvidersOnly(t *testing.T) { original := GetMetadataProviderPriority() defer SetMetadataProviderPriority(original) SetMetadataProviderPriority([]string{"tidal"}) got := GetMetadataProviderPriority() - want := []string{"tidal", "qobuz"} + want := []string{"tidal"} if len(got) != len(want) { t.Fatalf("unexpected priority length: got %v want %v", got, want) } @@ -80,7 +80,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) { SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"}) got := GetProviderPriority() - want := []string{"qobuz", "custom-ext", "tidal"} + want := []string{"qobuz", "custom-ext"} if len(got) != len(want) { t.Fatalf("unexpected priority length: got %v want %v", got, want) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 0eea9357..19db3434 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -424,23 +424,19 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "searchTidalAll": + case "searchProviderAll": let args = call.arguments as! [String: Any] + let providerId = args["provider_id"] as! String let query = args["query"] as! String let trackLimit = args["track_limit"] as? Int ?? 15 let artistLimit = args["artist_limit"] as? Int ?? 3 let filter = args["filter"] as? String ?? "" - let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + let response = GobackendSearchProviderAllJSON(providerId, query, Int(trackLimit), Int(artistLimit), filter, &error) if let error = error { throw error } return response - case "searchQobuzAll": - let args = call.arguments as! [String: Any] - let query = args["query"] as! String - let trackLimit = args["track_limit"] as? Int ?? 15 - let artistLimit = args["artist_limit"] as? Int ?? 3 - let filter = args["filter"] as? String ?? "" - let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + case "getBuiltInProviders": + let response = GobackendGetBuiltInProvidersJSON(&error) if let error = error { throw error } return response @@ -452,14 +448,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getDeezerMetadata": - let args = call.arguments as! [String: Any] - let resourceType = args["resource_type"] as! String - let resourceId = args["resource_id"] as! String - let response = GobackendGetDeezerMetadata(resourceType, resourceId, &error) - if let error = error { throw error } - return response - case "getQobuzMetadata": let args = call.arguments as! [String: Any] let resourceType = args["resource_type"] as! String @@ -476,24 +464,19 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "parseDeezerUrl": + case "getProviderMetadata": let args = call.arguments as! [String: Any] - let url = args["url"] as! String - let response = GobackendParseDeezerURLExport(url, &error) + let providerId = args["provider_id"] as! String + let resourceType = args["resource_type"] as! String + let resourceId = args["resource_id"] as! String + let response = GobackendGetProviderMetadataJSON(providerId, resourceType, resourceId, &error) if let error = error { throw error } return response - case "parseQobuzUrl": + case "parseProviderUrl": let args = call.arguments as! [String: Any] let url = args["url"] as! String - let response = GobackendParseQobuzURLExport(url, &error) - if let error = error { throw error } - return response - - case "parseTidalUrl": - let args = call.arguments as! [String: Any] - let url = args["url"] as! String - let response = GobackendParseTidalURLExport(url, &error) + let response = GobackendParseProviderURLJSON(url, &error) if let error = error { throw error } return response @@ -852,30 +835,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getAlbumWithExtension": - let args = call.arguments as! [String: Any] - let extensionId = args["extension_id"] as! String - let albumId = args["album_id"] as! String - let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error) - if let error = error { throw error } - return response - - case "getPlaylistWithExtension": - let args = call.arguments as! [String: Any] - let extensionId = args["extension_id"] as! String - let playlistId = args["playlist_id"] as! String - let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error) - if let error = error { throw error } - return response - - case "getArtistWithExtension": - let args = call.arguments as! [String: Any] - let extensionId = args["extension_id"] as! String - let artistId = args["artist_id"] as! String - let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error) - if let error = error { throw error } - return response - // Extension Post-Processing API case "runPostProcessing": let args = call.arguments as! [String: Any] diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 02a36f3f..d4748d9e 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2575,9 +2575,11 @@ class DownloadQueueNotifier extends Notifier { final providerTrackId = track.id.substring(colonIdx + 1); _log.d('No ISRC, fetching from $provider API: $providerTrackId'); - final providerData = provider == 'tidal' - ? await PlatformBridge.getTidalMetadata('track', providerTrackId) - : await PlatformBridge.getQobuzMetadata('track', providerTrackId); + final providerData = await PlatformBridge.getProviderMetadata( + provider, + 'track', + providerTrackId, + ); final trackData = providerData['track'] as Map?; if (trackData == null) { @@ -4448,7 +4450,8 @@ class DownloadQueueNotifier extends Notifier { ); final rawId = trackToDownload.id.split(':')[1]; _log.d('Fetching full metadata for Deezer ID: $rawId'); - final fullData = await PlatformBridge.getDeezerMetadata( + final fullData = await PlatformBridge.getProviderMetadata( + 'deezer', 'track', rawId, ); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index a969d78e..32d19547 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -15,6 +15,44 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; const _spotifyWebExtensionId = 'spotify-web'; +class BuiltInProviderSpec { + final String id; + final String displayName; + final bool supportsMetadata; + final bool supportsDownload; + final bool supportsSearch; + + const BuiltInProviderSpec({ + required this.id, + required this.displayName, + this.supportsMetadata = false, + this.supportsDownload = false, + this.supportsSearch = false, + }); + + factory BuiltInProviderSpec.fromJson(Map json) { + return BuiltInProviderSpec( + id: json['id'] as String? ?? '', + displayName: + json['display_name'] as String? ?? + json['displayName'] as String? ?? + '', + supportsMetadata: json['supports_metadata'] as bool? ?? false, + supportsDownload: json['supports_download'] as bool? ?? false, + supportsSearch: json['supports_search'] as bool? ?? false, + ); + } +} + +List _builtInProviderRegistry = const []; + +List get builtInProviderSpecs => + List.unmodifiable(_builtInProviderRegistry); + +void _replaceBuiltInProviderRegistry(List providers) { + _builtInProviderRegistry = List.unmodifiable(providers); +} + class Extension { final String id; final String name; @@ -195,6 +233,83 @@ class Extension { } } +BuiltInProviderSpec? builtInProviderSpecForId(String? providerId) { + if (providerId == null) return null; + + for (final provider in builtInProviderSpecs) { + if (provider.id == providerId) { + return provider; + } + } + + return null; +} + +List _builtInProvidersWhere( + bool Function(BuiltInProviderSpec provider) predicate, +) { + return List.unmodifiable( + builtInProviderSpecs.where(predicate), + ); +} + +List get builtInSearchProviderSpecs => + _builtInProvidersWhere((provider) => provider.supportsSearch); + +List get builtInMetadataProviderSpecs => + _builtInProvidersWhere((provider) => provider.supportsMetadata); + +List get builtInDownloadProviderSpecs => + _builtInProvidersWhere((provider) => provider.supportsDownload); + +List get builtInSearchProviderIds => List.unmodifiable( + builtInSearchProviderSpecs.map((provider) => provider.id), +); + +List get builtInMetadataProviderIds => List.unmodifiable( + builtInMetadataProviderSpecs.map((provider) => provider.id), +); + +List get builtInDownloadProviderIds => List.unmodifiable( + builtInDownloadProviderSpecs.map((provider) => provider.id), +); + +bool isBuiltInSearchProvider(String? providerId) => + builtInProviderSpecForId(providerId)?.supportsSearch ?? false; + +bool isBuiltInMetadataProvider(String? providerId) => + builtInProviderSpecForId(providerId)?.supportsMetadata ?? false; + +bool isBuiltInDownloadProvider(String? providerId) => + builtInProviderSpecForId(providerId)?.supportsDownload ?? false; + +String? get defaultBuiltInSearchProviderId => builtInSearchProviderSpecs.isEmpty + ? null + : builtInSearchProviderSpecs.first.id; + +String? get defaultBuiltInSearchProviderDisplayName => + builtInSearchProviderSpecs.isEmpty + ? null + : builtInSearchProviderSpecs.first.displayName; + +String resolveProviderDisplayName( + String providerId, { + Iterable extensions = const [], +}) { + final builtIn = builtInProviderSpecForId(providerId); + if (builtIn != null) { + return builtIn.displayName; + } + + for (final extension in extensions) { + if (extension.id == providerId) { + return extension.displayName; + } + } + + return providerId; +} + class SearchFilter { final String id; final String? label; @@ -460,6 +575,7 @@ class ExtensionSetting { class ExtensionState { final List extensions; + final List builtInProviders; final List providerPriority; final List metadataProviderPriority; final bool isLoading; @@ -468,6 +584,7 @@ class ExtensionState { const ExtensionState({ this.extensions = const [], + this.builtInProviders = const [], this.providerPriority = const [], this.metadataProviderPriority = const [], this.isLoading = false, @@ -477,6 +594,7 @@ class ExtensionState { ExtensionState copyWith({ List? extensions, + List? builtInProviders, List? providerPriority, List? metadataProviderPriority, bool? isLoading, @@ -485,6 +603,7 @@ class ExtensionState { }) { return ExtensionState( extensions: extensions ?? this.extensions, + builtInProviders: builtInProviders ?? this.builtInProviders, providerPriority: providerPriority ?? this.providerPriority, metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, @@ -496,7 +615,6 @@ class ExtensionState { } class ExtensionNotifier extends Notifier { - static const _builtInMetadataProviders = ['qobuz', 'tidal']; AppLifecycleListener? _appLifecycleListener; bool _cleanupInFlight = false; Completer? _initializationCompleter; @@ -547,6 +665,12 @@ class ExtensionNotifier extends Notifier { state = state.copyWith(isLoading: true, error: null); + try { + await refreshBuiltInProviders(); + } catch (e) { + _log.w('Failed to refresh built-in providers before init: $e'); + } + if (!PlatformBridge.supportsExtensionSystem) { state = state.copyWith( isInitialized: true, @@ -634,6 +758,16 @@ class ExtensionNotifier extends Notifier { } } + Future refreshBuiltInProviders() async { + final list = await PlatformBridge.getBuiltInProviders(); + final providers = list + .map((e) => BuiltInProviderSpec.fromJson(e)) + .where((provider) => provider.id.isNotEmpty) + .toList(); + _replaceBuiltInProviderRegistry(providers); + state = state.copyWith(builtInProviders: providers); + } + void clearError() { state = state.copyWith(error: null); } @@ -727,10 +861,16 @@ class ExtensionNotifier extends Notifier { } if (ext.hasDownloadProvider && settings.defaultService == extensionId) { - ref.read(settingsProvider.notifier).setDefaultService('tidal'); - _log.d( - 'Reset default service to Tidal because extension $extensionId was disabled', - ); + final availableProviders = getAllDownloadProviders(); + if (availableProviders.isNotEmpty) { + final fallbackService = availableProviders.first; + ref + .read(settingsProvider.notifier) + .setDefaultService(fallbackService); + _log.d( + 'Reset default service to $fallbackService because extension $extensionId was disabled', + ); + } } } } catch (e) { @@ -973,7 +1113,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz']; + final providers = List.from(builtInDownloadProviderIds); for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); @@ -995,7 +1135,7 @@ class ExtensionNotifier extends Notifier { return [ ...primarySearchMetadataExtensions, - ..._builtInMetadataProviders, + ...builtInMetadataProviderIds, ...otherMetadataExtensions, ]; } @@ -1012,10 +1152,10 @@ class ExtensionNotifier extends Notifier { } final hasPreferredExtension = preferredOrder.any( - (provider) => !_builtInMetadataProviders.contains(provider), + (provider) => !isBuiltInMetadataProvider(provider), ); final hasSavedExtension = result.any( - (provider) => !_builtInMetadataProviders.contains(provider), + (provider) => !isBuiltInMetadataProvider(provider), ); if (!hasSavedExtension && hasPreferredExtension) { diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 9547b6f3..2f961a13 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -353,212 +353,12 @@ class TrackNotifier extends Notifier { } } - if (url.contains('deezer.com') || url.contains('deezer.page.link')) { - _log.i('Detected Deezer URL, parsing...'); - final parsed = await PlatformBridge.parseDeezerUrl(url); - if (!_isRequestValid(requestId)) return; - - final type = parsed['type'] as String; - final id = parsed['id'] as String; - - final metadata = await PlatformBridge.getDeezerMetadata(type, id); - if (!_isRequestValid(requestId)) return; - - if (type == 'track') { - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - } else if (type == 'album') { - final albumInfo = metadata['album_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - state = TrackState( - tracks: tracks, - isLoading: false, - albumId: id, - albumName: albumInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'playlist') { - final playlistInfo = - metadata['playlist_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - state = TrackState( - tracks: tracks, - isLoading: false, - playlistName: playlistInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl( - playlistInfo['images']?.toString(), - ), - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'artist') { - final artistInfo = metadata['artist_info'] as Map; - final albumsList = metadata['albums'] as List; - final albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - state = TrackState( - tracks: [], - isLoading: false, - artistId: artistInfo['id'] as String?, - artistName: artistInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), - artistAlbums: albums, - ); - } - return; - } - - if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) { - _log.i('Detected Qobuz URL, parsing...'); - final parsed = await PlatformBridge.parseQobuzUrl(url); - if (!_isRequestValid(requestId)) return; - - final type = parsed['type'] as String; - final id = parsed['id'] as String; - - final metadata = await PlatformBridge.getQobuzMetadata(type, id); - if (!_isRequestValid(requestId)) return; - - if (type == 'track') { - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - } else if (type == 'album') { - final albumInfo = metadata['album_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - state = TrackState( - tracks: tracks, - isLoading: false, - albumId: 'qobuz:$id', - albumName: albumInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'playlist') { - final playlistInfo = - metadata['playlist_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - final owner = playlistInfo['owner'] as Map?; - final playlistName = - (playlistInfo['name'] ?? owner?['name']) as String?; - final coverUrl = normalizeRemoteHttpUrl( - (playlistInfo['images'] ?? owner?['images'])?.toString(), - ); - state = TrackState( - tracks: tracks, - isLoading: false, - playlistName: playlistName, - coverUrl: coverUrl, - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'artist') { - final artistInfo = metadata['artist_info'] as Map; - final albumsList = metadata['albums'] as List; - final albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - state = TrackState( - tracks: [], - isLoading: false, - artistId: artistInfo['id'] as String?, - artistName: artistInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), - artistAlbums: albums, - ); - } - return; - } - - if (url.contains('tidal.com')) { - _log.i('Detected Tidal URL, parsing...'); - final parsed = await PlatformBridge.parseTidalUrl(url); - if (!_isRequestValid(requestId)) return; - - final type = parsed['type'] as String; - final id = parsed['id'] as String; - - final metadata = await PlatformBridge.getTidalMetadata(type, id); - if (!_isRequestValid(requestId)) return; - - if (type == 'track') { - final trackData = metadata['track'] as Map; - final track = _parseTrack(trackData); - state = TrackState( - tracks: [track], - isLoading: false, - coverUrl: track.coverUrl, - ); - } else if (type == 'album') { - final albumInfo = metadata['album_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - state = TrackState( - tracks: tracks, - isLoading: false, - albumId: 'tidal:$id', - albumName: albumInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'playlist') { - final playlistInfo = - metadata['playlist_info'] as Map; - final trackList = metadata['track_list'] as List; - final tracks = trackList - .map((t) => _parseTrack(t as Map)) - .toList(); - final owner = playlistInfo['owner'] as Map?; - final playlistName = - (playlistInfo['name'] ?? owner?['name']) as String?; - final coverUrl = normalizeRemoteHttpUrl( - (playlistInfo['images'] ?? owner?['images'])?.toString(), - ); - state = TrackState( - tracks: tracks, - isLoading: false, - playlistName: playlistName, - coverUrl: coverUrl, - ); - _preWarmCacheForTracks(tracks); - } else if (type == 'artist') { - final artistInfo = metadata['artist_info'] as Map; - final albumsList = metadata['albums'] as List; - final albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - state = TrackState( - tracks: [], - isLoading: false, - artistId: artistInfo['id'] as String?, - artistName: artistInfo['name'] as String?, - coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), - artistAlbums: albums, - ); - } + final handledBuiltInUrl = await _tryResolveBuiltInProviderUrl( + url, + requestId, + ); + if (!_isRequestValid(requestId)) return; + if (handledBuiltInUrl) { return; } @@ -577,6 +377,134 @@ class TrackNotifier extends Notifier { } } + Future _tryResolveBuiltInProviderUrl(String url, int requestId) async { + Map parsed; + try { + parsed = await PlatformBridge.parseProviderUrl(url); + } catch (_) { + return false; + } + + if (!_isRequestValid(requestId)) return true; + + final providerId = parsed['provider_id']?.toString(); + final type = parsed['type']?.toString(); + final id = parsed['id']?.toString(); + if (providerId == null || + providerId.isEmpty || + type == null || + type.isEmpty || + id == null || + id.isEmpty) { + return false; + } + + _log.i('Detected built-in provider URL: $providerId:$type:$id'); + + final metadata = await _getResolvedProviderMetadata(providerId, type, id); + if (!_isRequestValid(requestId)) return true; + + _applyResolvedProviderMetadata(providerId, type, id, metadata); + return true; + } + + Future> _getResolvedProviderMetadata( + String providerId, + String resourceType, + String resourceId, + ) async { + return PlatformBridge.getProviderMetadata( + providerId, + resourceType, + resourceId, + ); + } + + void _applyResolvedProviderMetadata( + String providerId, + String resourceType, + String resourceId, + Map metadata, + ) { + switch (resourceType) { + case 'track': + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + return; + case 'album': + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: _buildResolvedAlbumId(providerId, resourceId), + albumName: albumInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()), + ); + _preWarmCacheForTracks(tracks); + return; + case 'playlist': + final playlistInfo = metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList + .map((t) => _parseTrack(t as Map)) + .toList(); + final owner = playlistInfo['owner'] as Map?; + final playlistName = + (playlistInfo['name'] ?? owner?['name']) as String?; + final coverUrl = normalizeRemoteHttpUrl( + (playlistInfo['images'] ?? owner?['images'])?.toString(), + ); + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: playlistName, + coverUrl: coverUrl, + ); + _preWarmCacheForTracks(tracks); + return; + case 'artist': + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + final topTracksList = metadata['top_tracks'] as List? ?? []; + final topTracks = topTracksList + .map((t) => _parseTrack(t as Map)) + .toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()), + headerImageUrl: normalizeRemoteHttpUrl( + (artistInfo['header_image'] ?? artistInfo['cover_url'])?.toString(), + ), + monthlyListeners: artistInfo['listeners'] as int?, + artistAlbums: albums, + artistTopTracks: topTracks.isNotEmpty ? topTracks : null, + ); + return; + } + } + + String _buildResolvedAlbumId(String providerId, String resourceId) { + if (providerId == 'deezer') { + return resourceId; + } + return '$providerId:$resourceId'; + } + Future search( String query, { String? filterOverride, @@ -609,19 +537,15 @@ class TrackNotifier extends Notifier { .map((ext) => ext.id) .firstOrNull; } - resolvedProvider ??= 'tidal'; + resolvedProvider ??= defaultBuiltInSearchProviderId; } - final isEnabledExtensionProvider = + if (resolvedProvider != null && resolvedProvider.isNotEmpty && - extensionState.extensions.any( + !isBuiltInSearchProvider(resolvedProvider) && + !extensionState.extensions.any( (ext) => ext.enabled && ext.id == resolvedProvider, - ); - - if (resolvedProvider.isNotEmpty && - resolvedProvider != 'tidal' && - resolvedProvider != 'qobuz' && - !isEnabledExtensionProvider && + ) && settings.searchProvider?.trim() == resolvedProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); resolvedProvider = @@ -638,15 +562,21 @@ class TrackNotifier extends Notifier { .where((ext) => ext.enabled && ext.hasCustomSearch) .map((ext) => ext.id) .firstOrNull; - resolvedProvider ??= 'tidal'; + resolvedProvider ??= defaultBuiltInSearchProviderId; } - if (resolvedProvider.isNotEmpty && - resolvedProvider != 'tidal' && - resolvedProvider != 'qobuz' && + final isEnabledExtensionProvider = + resolvedProvider != null && + resolvedProvider.isNotEmpty && extensionState.extensions.any( (ext) => ext.enabled && ext.id == resolvedProvider, - )) { + ); + final isBuiltInProvider = isBuiltInSearchProvider(resolvedProvider); + + if (resolvedProvider != null && + resolvedProvider.isNotEmpty && + !isBuiltInProvider && + isEnabledExtensionProvider) { final resolvedFilter = requestFilter ?? 'track'; Map? options; options = {'filter': resolvedFilter}; @@ -659,12 +589,12 @@ class TrackNotifier extends Notifier { return; } - final effectiveBuiltInProvider = - resolvedProvider == 'tidal' || resolvedProvider == 'qobuz' + final fallbackBuiltInProvider = builtInSearchProvider?.isNotEmpty == true + ? builtInSearchProvider + : defaultBuiltInSearchProviderId; + final effectiveBuiltInProvider = isBuiltInProvider ? resolvedProvider - : (builtInSearchProvider?.isNotEmpty == true - ? builtInSearchProvider - : 'tidal'); + : fallbackBuiltInProvider; if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) { state = TrackState( @@ -700,40 +630,29 @@ class TrackNotifier extends Notifier { Map results; List> metadataTrackResults = []; - switch (effectiveProvider) { - case 'tidal': - _log.d('Calling Tidal search API...'); - results = await PlatformBridge.searchTidalAll( - query, - trackLimit: 20, - artistLimit: 2, - filter: requestFilter, - ); - break; - case 'qobuz': - _log.d('Calling Qobuz search API...'); - results = await PlatformBridge.searchQobuzAll( - query, - trackLimit: 20, - artistLimit: 2, - filter: requestFilter, - ); - break; - default: - _log.d('Calling metadata provider track search API...'); - metadataTrackResults = - await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 20, - includeExtensions: includeExtensions, - ); - results = const >{ - 'tracks': [], - 'artists': [], - 'albums': [], - 'playlists': [], - }; - break; + if (isBuiltInSearchProvider(effectiveProvider)) { + _log.d('Calling built-in search API for $effectiveProvider...'); + results = await PlatformBridge.searchProviderAll( + effectiveProvider, + query, + trackLimit: 20, + artistLimit: 2, + filter: requestFilter, + ); + } else { + _log.d('Calling metadata provider track search API...'); + metadataTrackResults = + await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 20, + includeExtensions: includeExtensions, + ); + results = const >{ + 'tracks': [], + 'artists': [], + 'albums': [], + 'playlists': [], + }; } _log.i( '$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 07ece414..dca498ab 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -176,83 +176,12 @@ class _AlbumScreenState extends ConsumerState { Future _fetchTracks() async { setState(() => _isLoading = true); try { - if (widget.albumId.startsWith('deezer:')) { - final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); - final metadata = await PlatformBridge.getDeezerMetadata( + final directProviderId = _directMetadataProviderId(); + if (directProviderId != null) { + final metadata = await PlatformBridge.getProviderMetadata( + directProviderId, 'album', - deezerAlbumId, - ); - final trackList = metadata['track_list'] as List; - final albumInfo = metadata['album_info'] as Map?; - final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) - ?.toString(); - final albumType = normalizeOptionalString( - albumInfo?['album_type']?.toString(), - ); - final totalTracks = albumInfo?['total_tracks'] as int?; - final tracks = trackList - .map( - (t) => _parseTrack( - t as Map, - albumTypeFallback: albumType, - totalTracksFallback: totalTracks, - ), - ) - .toList(); - - _AlbumCache.set(widget.albumId, tracks); - - if (mounted) { - setState(() { - _tracks = tracks; - _artistId = artistId; - _albumType = albumType; - _albumTotalTracks = totalTracks; - _isLoading = false; - }); - } - return; - } else if (widget.albumId.startsWith('qobuz:')) { - final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', ''); - final metadata = await PlatformBridge.getQobuzMetadata( - 'album', - qobuzAlbumId, - ); - final trackList = metadata['track_list'] as List; - final albumInfo = metadata['album_info'] as Map?; - final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId']) - ?.toString(); - final albumType = normalizeOptionalString( - albumInfo?['album_type']?.toString(), - ); - final totalTracks = albumInfo?['total_tracks'] as int?; - final tracks = trackList - .map( - (t) => _parseTrack( - t as Map, - albumTypeFallback: albumType, - totalTracksFallback: totalTracks, - ), - ) - .toList(); - - _AlbumCache.set(widget.albumId, tracks); - - if (mounted) { - setState(() { - _tracks = tracks; - _artistId = artistId; - _albumType = albumType; - _albumTotalTracks = totalTracks; - _isLoading = false; - }); - } - return; - } else if (widget.albumId.startsWith('tidal:')) { - final tidalAlbumId = widget.albumId.replaceFirst('tidal:', ''); - final metadata = await PlatformBridge.getTidalMetadata( - 'album', - tidalAlbumId, + _metadataResourceId(directProviderId), ); final trackList = metadata['track_list'] as List; final albumInfo = metadata['album_info'] as Map?; @@ -332,6 +261,24 @@ 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; + } + + String _metadataResourceId(String providerId) { + final prefixed = '$providerId:'; + if (widget.albumId.startsWith(prefixed)) { + return widget.albumId.substring(prefixed.length); + } + return widget.albumId; + } + Track _parseTrack( Map data, { String? albumTypeFallback, @@ -366,12 +313,7 @@ class _AlbumScreenState extends ConsumerState { } String? _recommendedDownloadService() { - if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { - return widget.extensionId; - } - if (widget.albumId.startsWith('tidal:')) return 'tidal'; - if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; - return null; + return _directMetadataProviderId(); } @override diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 73cec937..4391f127 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -154,14 +154,27 @@ class _ArtistScreenState extends ConsumerState { } String? _recommendedDownloadService() { + return _directMetadataProviderId(); + } + + String? _directMetadataProviderId() { if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { return widget.extensionId; } - if (widget.artistId.startsWith('tidal:')) return 'tidal'; + if (widget.artistId.startsWith('deezer:')) return 'deezer'; if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; + if (widget.artistId.startsWith('tidal:')) return 'tidal'; return null; } + String _metadataResourceId(String providerId) { + final prefixed = '$providerId:'; + if (widget.artistId.startsWith(prefixed)) { + return widget.artistId.substring(prefixed.length); + } + return widget.artistId; + } + @override void initState() { super.initState(); @@ -250,51 +263,13 @@ class _ArtistScreenState extends ConsumerState { String? headerImage; int? listeners; - if (widget.artistId.startsWith('deezer:')) { - final deezerArtistId = widget.artistId.replaceFirst('deezer:', ''); - final metadata = await PlatformBridge.getDeezerMetadata( + if (_directMetadataProviderId() != null) { + final providerId = _directMetadataProviderId()!; + final artistData = await PlatformBridge.getProviderMetadata( + providerId, 'artist', - deezerArtistId, + _metadataResourceId(providerId), ); - final albumsList = metadata['albums'] as List; - albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - } else if (widget.artistId.startsWith('qobuz:')) { - final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', ''); - final metadata = await PlatformBridge.getQobuzMetadata( - 'artist', - qobuzArtistId, - ); - final albumsList = metadata['albums'] as List; - albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - final artistInfo = metadata['artist_info'] as Map?; - headerImage = artistInfo?['images'] as String?; - } else if (widget.artistId.startsWith('tidal:')) { - final tidalArtistId = widget.artistId.replaceFirst('tidal:', ''); - final metadata = await PlatformBridge.getTidalMetadata( - 'artist', - tidalArtistId, - ); - final albumsList = metadata['albums'] as List; - albums = albumsList - .map((a) => _parseArtistAlbum(a as Map)) - .toList(); - final artistInfo = metadata['artist_info'] as Map?; - headerImage = artistInfo?['images'] as String?; - } else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { - final result = await PlatformBridge.getArtistWithExtension( - widget.extensionId!, - widget.artistId, - ); - - if (result == null) { - throw Exception('Failed to load artist from extension'); - } - - final artistData = result; final albumsList = artistData['albums'] as List? ?? []; albums = albumsList .map((a) => _parseArtistAlbum(a as Map)) @@ -314,11 +289,16 @@ class _ArtistScreenState extends ConsumerState { .toList(); } + final artistInfo = artistData['artist_info'] as Map?; headerImage = + artistInfo?['images'] as String? ?? + artistInfo?['header_image'] as String? ?? + artistInfo?['cover_url'] as String? ?? artistData['header_image'] as String? ?? artistData['cover_url'] as String? ?? artistData['image_url'] as String?; - listeners = artistData['listeners'] as int?; + listeners = + artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?; } else { final url = 'https://open.spotify.com/artist/${widget.artistId}'; final result = await PlatformBridge.handleURLWithExtension(url); @@ -1051,42 +1031,16 @@ class _ArtistScreenState extends ConsumerState { } Future> _fetchAlbumTracks(ArtistAlbum album) async { - if (album.providerId != null && album.providerId!.isNotEmpty) { - final result = await PlatformBridge.getAlbumWithExtension( - album.providerId!, - album.id, - ); - if (result != null && result['tracks'] != null) { - final tracksList = result['tracks'] as List; - final parsedTracks = tracksList - .map((t) => _parseTrack(t as Map, album: album)) - .toList(); - return parsedTracks; - } - } else if (album.id.startsWith('deezer:')) { - final deezerId = album.id.replaceFirst('deezer:', ''); - final metadata = await PlatformBridge.getDeezerMetadata( + final providerId = album.providerId; + if (providerId != null && providerId.isNotEmpty) { + final resourceId = album.id.startsWith('$providerId:') + ? album.id.substring(providerId.length + 1) + : album.id; + final metadata = await PlatformBridge.getProviderMetadata( + providerId, 'album', - deezerId, + resourceId, ); - if (metadata['tracks'] != null) { - final tracksList = metadata['tracks'] as List; - return tracksList - .map((t) => _parseTrackFromDeezer(t as Map, album)) - .toList(); - } - } else if (album.id.startsWith('qobuz:')) { - final qobuzId = album.id.replaceFirst('qobuz:', ''); - final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId); - if (metadata['track_list'] != null) { - final tracksList = metadata['track_list'] as List; - return tracksList - .map((t) => _parseTrack(t as Map, album: album)) - .toList(); - } - } else if (album.id.startsWith('tidal:')) { - final tidalId = album.id.replaceFirst('tidal:', ''); - final metadata = await PlatformBridge.getTidalMetadata('album', tidalId); if (metadata['track_list'] != null) { final tracksList = metadata['track_list'] as List; return tracksList @@ -1106,41 +1060,6 @@ class _ArtistScreenState extends ConsumerState { return []; } - Track _parseTrackFromDeezer(Map data, ArtistAlbum album) { - int durationMs = 0; - final durationValue = data['duration']; - final artistData = data['artist']; - final artistName = artistData is Map - ? (artistData['name'] as String? ?? widget.artistName) - : (artistData?.toString() ?? widget.artistName); - if (durationValue is int) { - durationMs = durationValue * 1000; // Deezer returns seconds - } else if (durationValue is double) { - durationMs = (durationValue * 1000).toInt(); - } - - return Track( - id: 'deezer:${data['id']}', - name: (data['title'] ?? data['name'] ?? '').toString(), - artistName: artistName, - albumName: album.name, - albumArtist: null, - artistId: widget.artistId, - albumId: album.id.isNotEmpty ? album.id : null, - coverUrl: album.coverUrl, - isrc: data['isrc']?.toString(), - duration: (durationMs / 1000).round(), - trackNumber: - data['track_position'] as int? ?? data['track_number'] as int?, - discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?, - totalDiscs: data['total_discs'] as int?, - releaseDate: album.releaseDate, - albumType: album.albumType, - totalTracks: album.totalTracks, - composer: data['composer']?.toString(), - ); - } - Widget _buildHeader( BuildContext context, ColorScheme colorScheme, { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e653e282..1e697ddc 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/utils/provider_ui_utils.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -481,13 +482,14 @@ class _HomeTabState extends ConsumerState final explicit = explicitSearchProvider?.trim(); if (explicit != null && explicit.isNotEmpty && - (_builtInSearchProviders.contains(explicit) || + (isBuiltInSearchProvider(explicit) || extensions.any( (ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit, ))) { return explicit; } - return _defaultSearchExtension(extensions)?.id ?? 'tidal'; + return _defaultSearchExtension(extensions)?.id ?? + defaultBuiltInSearchProviderId; } String? _sanitizeSearchFilterForProvider( @@ -503,7 +505,7 @@ class _HomeTabState extends ConsumerState if (currentSearchProvider == null || currentSearchProvider.isEmpty || - _builtInSearchProviders.contains(currentSearchProvider)) { + isBuiltInSearchProvider(currentSearchProvider)) { switch (canonicalFilter) { case 'track': case 'artist': @@ -695,8 +697,7 @@ class _HomeTabState extends ConsumerState if (searchProvider == null || searchProvider.isEmpty) return false; - // Built-in providers (tidal, qobuz) also support live search - if (_builtInSearchProviders.contains(searchProvider)) return true; + if (isBuiltInSearchProvider(searchProvider)) return true; final extension = extState.extensions .where((e) => e.id == searchProvider && e.enabled) @@ -755,8 +756,6 @@ class _HomeTabState extends ConsumerState } } - static const _builtInSearchProviders = {'tidal', 'qobuz'}; - Future _performSearch(String query, {String? filterOverride}) async { final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); @@ -795,8 +794,7 @@ class _HomeTabState extends ConsumerState _invalidateSearchSortCaches(); final isBuiltInProvider = - searchProvider != null && - _builtInSearchProviders.contains(searchProvider); + searchProvider != null && isBuiltInSearchProvider(searchProvider); final isExtensionEnabled = searchProvider != null && @@ -3347,11 +3345,9 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { - if (searchProvider == 'tidal') { - return 'Search with Tidal...'; - } - if (searchProvider == 'qobuz') { - return 'Search with Qobuz...'; + final builtIn = builtInProviderSpecForId(searchProvider); + if (builtIn != null && builtIn.supportsSearch) { + return 'Search with ${builtIn.displayName}...'; } final ext = extState.extensions @@ -3566,16 +3562,19 @@ class _SearchProviderDropdown extends ConsumerWidget { final searchProviders = extensions .where((ext) => ext.enabled && ext.hasCustomSearch) .toList(); + final builtInProviders = builtInSearchProviderSpecs; final primarySearchExtension = _defaultSearchExtension(searchProviders); final defaultProviderTarget = - primarySearchExtension?.displayName ?? 'Tidal'; + primarySearchExtension?.displayName ?? + defaultBuiltInSearchProviderDisplayName ?? + context.l10n.extensionDefaultProvider; final defaultProviderLabel = '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; final defaultProviderIconPath = primarySearchExtension?.iconPath; final currentProvider = rawCurrentProvider != null && rawCurrentProvider.isNotEmpty && - ({'tidal', 'qobuz'}.contains(rawCurrentProvider) || + (isBuiltInSearchProvider(rawCurrentProvider) || searchProviders.any((e) => e.id == rawCurrentProvider)) ? rawCurrentProvider : null; @@ -3587,9 +3586,8 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } - const builtInProviders = {'tidal', 'qobuz'}; final isBuiltInProvider = - currentProvider != null && builtInProviders.contains(currentProvider); + currentProvider != null && isBuiltInSearchProvider(currentProvider); IconData displayIcon = Icons.search; String? iconPath; @@ -3612,7 +3610,7 @@ class _SearchProviderDropdown extends ConsumerWidget { ); } } else if (isBuiltInProvider) { - displayIcon = Icons.music_note; + displayIcon = resolveProviderIcon(currentProvider); } return Padding( @@ -3679,58 +3677,33 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - PopupMenuItem( - value: 'tidal', - child: Row( - children: [ - Icon( - Icons.music_note, - size: 20, - color: currentProvider == 'tidal' - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Tidal', - style: TextStyle( - fontWeight: currentProvider == 'tidal' - ? FontWeight.w600 - : FontWeight.normal, + ...builtInProviders.map( + (provider) => PopupMenuItem( + value: provider.id, + child: Row( + children: [ + Icon( + resolveProviderIcon(provider.id), + size: 20, + color: currentProvider == provider.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + provider.displayName, + style: TextStyle( + fontWeight: currentProvider == provider.id + ? FontWeight.w600 + : FontWeight.normal, + ), ), ), - ), - if (currentProvider == 'tidal') - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], - ), - ), - PopupMenuItem( - value: 'qobuz', - child: Row( - children: [ - Icon( - Icons.music_note, - size: 20, - color: currentProvider == 'qobuz' - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Qobuz', - style: TextStyle( - fontWeight: currentProvider == 'qobuz' - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - if (currentProvider == 'qobuz') - Icon(Icons.check, size: 18, color: colorScheme.primary), - ], + if (currentProvider == provider.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), ), ), if (searchProviders.isNotEmpty) const PopupMenuDivider(), @@ -4627,21 +4600,17 @@ class _ExtensionAlbumScreenState extends ConsumerState { }); try { - final result = await PlatformBridge.getAlbumWithExtension( + final result = await PlatformBridge.getProviderMetadata( widget.extensionId, + 'album', widget.albumId, ); if (!mounted) return; - if (result == null) { - setState(() { - _error = context.l10n.errorLoadAlbum; - _isLoading = false; - }); - return; - } - - final trackList = result['tracks'] as List?; + final albumInfo = result['album_info'] as Map? ?? result; + final trackList = + result['track_list'] as List? ?? + result['tracks'] as List?; if (trackList == null) { setState(() { _error = context.l10n.errorNoTracksFound; @@ -4650,12 +4619,15 @@ class _ExtensionAlbumScreenState extends ConsumerState { return; } - final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); - final artistName = result['artists'] as String?; + final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId']) + ?.toString(); + final artistName = (albumInfo['artists'] ?? albumInfo['artist']) + ?.toString(); final albumType = - normalizeOptionalString(result['album_type']?.toString()) ?? + normalizeOptionalString(albumInfo['album_type']?.toString()) ?? _albumType; - final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks; + final totalTracks = + albumInfo['total_tracks'] as int? ?? _albumTotalTracks; final tracks = trackList .map( (t) => _parseTrack( @@ -4818,21 +4790,16 @@ class _ExtensionPlaylistScreenState }); try { - final result = await PlatformBridge.getPlaylistWithExtension( + final result = await PlatformBridge.getProviderMetadata( widget.extensionId, + 'playlist', widget.playlistId, ); if (!mounted) return; - if (result == null) { - setState(() { - _error = context.l10n.errorLoadPlaylist; - _isLoading = false; - }); - return; - } - - final trackList = result['tracks'] as List?; + final trackList = + result['track_list'] as List? ?? + result['tracks'] as List?; if (trackList == null) { setState(() { _error = context.l10n.errorNoTracksFound; @@ -4975,20 +4942,15 @@ class _ExtensionArtistScreenState extends ConsumerState { }); try { - final result = await PlatformBridge.getArtistWithExtension( + final result = await PlatformBridge.getProviderMetadata( widget.extensionId, + 'artist', widget.artistId, ); if (!mounted) return; - if (result == null) { - setState(() { - _error = context.l10n.errorLoadArtist; - _isLoading = false; - }); - return; - } - + final artistInfo = + result['artist_info'] as Map? ?? result; final albumList = result['albums'] as List?; final albums = albumList @@ -5004,8 +4966,13 @@ class _ExtensionArtistScreenState extends ConsumerState { .toList(); } - final headerImage = result['header_image'] as String?; - final listeners = result['listeners'] as int?; + final headerImage = + artistInfo['images'] as String? ?? + artistInfo['header_image'] as String? ?? + artistInfo['cover_url'] as String? ?? + result['header_image'] as String?; + final listeners = + artistInfo['listeners'] as int? ?? result['listeners'] as int?; setState(() { _albums = albums; diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index bdec0839..32afe598 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -51,6 +51,21 @@ 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'; + return null; + } + + String _metadataResourceId(String providerId, String playlistId) { + final prefixed = '$providerId:'; + if (playlistId.startsWith(prefixed)) { + return playlistId.substring(prefixed.length); + } + return playlistId; + } + String? _recommendedDownloadService() { final explicit = widget.recommendedService; if (explicit != null && explicit.isNotEmpty) { @@ -99,17 +114,19 @@ class _PlaylistScreenState extends ConsumerState { try { String playlistId = widget.playlistId!; late final Map result; - if (playlistId.startsWith('deezer:')) { - playlistId = playlistId.substring(7); - result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); - } else if (playlistId.startsWith('qobuz:')) { - playlistId = playlistId.substring(6); - result = await PlatformBridge.getQobuzMetadata('playlist', playlistId); - } else if (playlistId.startsWith('tidal:')) { - playlistId = playlistId.substring(6); - result = await PlatformBridge.getTidalMetadata('playlist', playlistId); + final providerId = _metadataProviderId(playlistId); + if (providerId != null) { + result = await PlatformBridge.getProviderMetadata( + providerId, + 'playlist', + _metadataResourceId(providerId, playlistId), + ); } else { - result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); + result = await PlatformBridge.getProviderMetadata( + 'deezer', + 'playlist', + playlistId, + ); } if (!mounted) return; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 543dbc1f..8e6d3901 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/provider_ui_utils.dart'; import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -24,7 +25,6 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz']; static const _songLinkRegions = [ 'AD', 'AE', @@ -300,7 +300,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - final isBuiltInService = _builtInServices.contains(settings.defaultService); + final isBuiltInService = isBuiltInDownloadProvider(settings.defaultService); final isTidalService = settings.defaultService == 'tidal'; return PopScope( @@ -2053,13 +2053,13 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz']; + final builtInProviders = builtInDownloadProviderSpecs; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - final isExtensionService = !builtInServiceIds.contains(currentService); + final isExtensionService = !isBuiltInDownloadProvider(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) : true; @@ -2070,25 +2070,17 @@ class _ServiceSelector extends ConsumerWidget { padding: const EdgeInsets.all(12), child: Column( children: [ - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Expanded( - child: _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: effectiveService == 'tidal', - onTap: () => onChanged('tidal'), + for (final provider in builtInProviders) + _ServiceChip( + icon: resolveProviderIcon(provider.id), + label: provider.displayName, + isSelected: effectiveService == provider.id, + onTap: () => onChanged(provider.id), ), - ), - const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: effectiveService == 'qobuz', - onTap: () => onChanged('qobuz'), - ), - ), ], ), if (extensionProviders.isNotEmpty) ...[ diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index eac29a1e..99341fc0 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -660,26 +660,27 @@ class _DownloadFallbackItem extends ConsumerWidget { class _SearchProviderSelector extends ConsumerWidget { const _SearchProviderSelector(); - static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; - @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); final colorScheme = Theme.of(context).colorScheme; + final builtInProviders = builtInSearchProviderSpecs; final searchProviders = extState.extensions .where((e) => e.enabled && e.hasCustomSearch) .toList(); final hasAnyProvider = - searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; + searchProviders.isNotEmpty || builtInProviders.isNotEmpty; String currentProviderName = context.l10n.extensionDefaultProvider; if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - if (_builtInProviders.containsKey(settings.searchProvider)) { - currentProviderName = _builtInProviders[settings.searchProvider]!; + if (isBuiltInSearchProvider(settings.searchProvider)) { + currentProviderName = resolveProviderDisplayName( + settings.searchProvider!, + ); } else { final ext = searchProviders .where((e) => e.id == settings.searchProvider) @@ -754,6 +755,7 @@ class _SearchProviderSelector extends ConsumerWidget { List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; + final builtInProviders = builtInSearchProviderSpecs; showModalBottomSheet( context: context, @@ -800,18 +802,20 @@ class _SearchProviderSelector extends ConsumerWidget { Navigator.pop(ctx); }, ), - ..._builtInProviders.entries.map( - (entry) => ListTile( + ...builtInProviders.map( + (provider) => ListTile( leading: Icon(Icons.search, color: colorScheme.tertiary), - title: Text(entry.value), - subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)), - trailing: settings.searchProvider == entry.key + title: Text(provider.displayName), + subtitle: Text( + ctx.l10n.extensionsSearchWith(provider.displayName), + ), + trailing: settings.searchProvider == provider.id ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline), onTap: () { ref .read(settingsProvider.notifier) - .setSearchProvider(entry.key); + .setSearchProvider(provider.id); Navigator.pop(ctx); }, ), diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 853e3916..c61fe930 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/utils/provider_ui_utils.dart'; import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart'; class MetadataProviderPriorityPage extends ConsumerStatefulWidget { @@ -220,36 +221,34 @@ class _MetadataProviderItem extends StatelessWidget { BuildContext context, String provider, ) { - switch (provider) { - case 'deezer': - return _MetadataProviderInfo( - name: 'Deezer', - icon: Icons.album, - description: context.l10n.providerExtension, - isBuiltIn: false, - ); - 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, - icon: Icons.extension, - description: context.l10n.providerExtension, - isBuiltIn: false, - ); + final builtIn = builtInProviderSpecForId(provider); + if (builtIn != null) { + return _MetadataProviderInfo( + name: builtIn.displayName, + icon: resolveProviderIcon( + provider, + builtInDefaultIcon: Icons.library_music, + ), + description: context.l10n.providerBuiltIn, + isBuiltIn: true, + ); } + + if (provider == 'deezer') { + return _MetadataProviderInfo( + name: 'Deezer', + icon: Icons.album, + description: context.l10n.providerExtension, + isBuiltIn: false, + ); + } + + return _MetadataProviderInfo( + name: provider, + icon: Icons.extension, + description: context.l10n.providerExtension, + isBuiltIn: false, + ); } } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 147394aa..7f28e405 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/provider_ui_utils.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class OptionsSettingsPage extends ConsumerWidget { @@ -717,8 +718,6 @@ class _ChannelChip extends StatelessWidget { class _MetadataSourceSelector extends ConsumerWidget { const _MetadataSourceSelector(); - static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; - Extension? _defaultSearchExtension(List extensions) { return extensions .where( @@ -738,12 +737,15 @@ class _MetadataSourceSelector extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); + final builtInProviders = builtInSearchProviderSpecs; final rawSearchProvider = settings.searchProvider?.trim() ?? ''; - final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider); + final isValidBuiltIn = isBuiltInSearchProvider(rawSearchProvider); final primarySearchExtension = _defaultSearchExtension(extState.extensions); final defaultProviderTarget = - primarySearchExtension?.displayName ?? 'Tidal'; + primarySearchExtension?.displayName ?? + defaultBuiltInSearchProviderDisplayName ?? + context.l10n.extensionDefaultProvider; final defaultProviderLabel = '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; final searchProvider = @@ -754,7 +756,7 @@ class _MetadataSourceSelector extends ConsumerWidget { ) ? rawSearchProvider : ''; - final isBuiltIn = _builtInProviders.containsKey(searchProvider); + final isBuiltIn = isBuiltInSearchProvider(searchProvider); Extension? activeExtension; if (searchProvider.isNotEmpty && !isBuiltIn) { @@ -766,7 +768,7 @@ class _MetadataSourceSelector extends ConsumerWidget { String subtitle; if (isBuiltIn) { - subtitle = 'Using ${_builtInProviders[searchProvider]}'; + subtitle = 'Using ${resolveProviderDisplayName(searchProvider)}'; } else if (activeExtension != null) { subtitle = context.l10n.optionsUsingExtension( activeExtension.displayName, @@ -796,48 +798,34 @@ class _MetadataSourceSelector extends ConsumerWidget { ), ), const SizedBox(height: 16), - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Expanded( - child: _SourceChip( - icon: Icons.graphic_eq, - label: defaultProviderLabel, - isSelected: searchProvider.isEmpty, - onTap: () { - if (hasNonDefaultProvider) { - ref - .read(settingsProvider.notifier) - .setSearchProvider(null); - } - }, - ), + _SourceChip( + icon: Icons.graphic_eq, + label: defaultProviderLabel, + isSelected: searchProvider.isEmpty, + onTap: () { + if (hasNonDefaultProvider) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + }, ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.waves, - label: 'Tidal', - isSelected: searchProvider == 'tidal', + for (final provider in builtInProviders) + _SourceChip( + icon: resolveProviderIcon( + provider.id, + tidalIcon: Icons.waves, + ), + label: provider.displayName, + isSelected: searchProvider == provider.id, onTap: () { ref .read(settingsProvider.notifier) - .setSearchProvider('tidal'); + .setSearchProvider(provider.id); }, ), - ), - const SizedBox(width: 8), - Expanded( - child: _SourceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: searchProvider == 'qobuz', - onTap: () { - ref - .read(settingsProvider.notifier) - .setSearchProvider('qobuz'); - }, - ), - ), ], ), if (activeExtension != null) ...[ diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index 3d73a06d..758f525c 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/utils/provider_ui_utils.dart'; class ProviderPriorityPage extends ConsumerStatefulWidget { const ProviderPriorityPage({super.key}); @@ -325,28 +326,28 @@ class _ProviderItem extends StatelessWidget { } _ProviderInfo _getProviderInfo(String provider) { - switch (provider) { - case 'tidal': - return _ProviderInfo( - name: 'Tidal', - icon: Icons.music_note, - isBuiltIn: true, - ); - case 'qobuz': - return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); - case 'deezer': - return _ProviderInfo( - name: 'Deezer', - icon: Icons.graphic_eq, - isBuiltIn: true, - ); - default: - return _ProviderInfo( - name: provider, - icon: Icons.extension, - isBuiltIn: false, - ); + final builtIn = builtInProviderSpecForId(provider); + if (builtIn != null) { + return _ProviderInfo( + name: builtIn.displayName, + icon: resolveProviderIcon(provider), + isBuiltIn: true, + ); } + + if (provider == 'deezer') { + return _ProviderInfo( + name: 'Deezer', + icon: Icons.graphic_eq, + isBuiltIn: false, + ); + } + + return _ProviderInfo( + name: provider, + icon: Icons.extension, + isBuiltIn: false, + ); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 04a83fd0..e4b69d3c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5040,7 +5040,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); if (deezerId != null) { - final deezerTrack = await PlatformBridge.getDeezerMetadata( + final deezerTrack = await PlatformBridge.getProviderMetadata( + 'deezer', 'track', deezerId, ); @@ -5291,7 +5292,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { (enriched['isrc'] ?? '').trim().isEmpty && deezerId != null) { try { - final deezerMeta = await PlatformBridge.getDeezerMetadata( + final deezerMeta = await PlatformBridge.getProviderMetadata( + 'deezer', 'track', deezerId, ); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8dc91b3a..8a48a947 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -496,28 +496,15 @@ class PlatformBridge { await _channel.invokeMethod('clearTrackCache'); } - static Future> searchTidalAll( + static Future> searchProviderAll( + String providerId, String query, { int trackLimit = 15, int artistLimit = 2, String? filter, }) async { - final result = await _channel.invokeMethod('searchTidalAll', { - 'query': query, - 'track_limit': trackLimit, - 'artist_limit': artistLimit, - 'filter': filter ?? '', - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchQobuzAll( - String query, { - int trackLimit = 15, - int artistLimit = 2, - String? filter, - }) async { - final result = await _channel.invokeMethod('searchQobuzAll', { + final result = await _channel.invokeMethod('searchProviderAll', { + 'provider_id': providerId, 'query': query, 'track_limit': trackLimit, 'artist_limit': artistLimit, @@ -537,27 +524,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> getDeezerMetadata( - String resourceType, - String resourceId, - ) async { - final result = await _channel.invokeMethod('getDeezerMetadata', { - 'resource_type': resourceType, - 'resource_id': resourceId, - }); - if (result == null) { - throw Exception( - 'getDeezerMetadata returned null for $resourceType:$resourceId', - ); - } - return jsonDecode(result as String) as Map; - } - - static Future> parseDeezerUrl(String url) async { - final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); - return jsonDecode(result as String) as Map; - } - static Future> getQobuzMetadata( String resourceType, String resourceId, @@ -574,13 +540,10 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> parseQobuzUrl(String url) async { - final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future> parseTidalUrl(String url) async { - final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); + static Future> parseProviderUrl(String url) async { + final result = await _channel.invokeMethod('parseProviderUrl', { + 'url': url, + }); return jsonDecode(result as String) as Map; } @@ -600,6 +563,24 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> getProviderMetadata( + String providerId, + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getProviderMetadata', { + 'provider_id': providerId, + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getProviderMetadata returned null for $providerId:$resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + static Future> convertTidalToSpotifyDeezer( String tidalUrl, ) async { @@ -969,6 +950,12 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + static Future>> getBuiltInProviders() async { + final result = await _channel.invokeMethod('getBuiltInProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + static Future?> handleURLWithExtension( String url, ) async { @@ -995,57 +982,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - static Future?> getAlbumWithExtension( - String extensionId, - String albumId, - ) async { - try { - final result = await _channel.invokeMethod('getAlbumWithExtension', { - 'extension_id': extensionId, - 'album_id': albumId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getAlbumWithExtension failed: $e'); - return null; - } - } - - static Future?> getPlaylistWithExtension( - String extensionId, - String playlistId, - ) async { - try { - final result = await _channel.invokeMethod('getPlaylistWithExtension', { - 'extension_id': extensionId, - 'playlist_id': playlistId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getPlaylistWithExtension failed: $e'); - return null; - } - } - - static Future?> getArtistWithExtension( - String extensionId, - String artistId, - ) async { - try { - final result = await _channel.invokeMethod('getArtistWithExtension', { - 'extension_id': extensionId, - 'artist_id': artistId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getArtistWithExtension failed: $e'); - return null; - } - } - static Future?> getExtensionHomeFeed( String extensionId, ) async { diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index c2e2cc29..65130785 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' @@ -216,9 +217,8 @@ void _pushAlbumScreen( String? coverUrl, String? extensionId, }) { - const builtInProviders = {'tidal', 'qobuz'}; final isExtension = - extensionId != null && !builtInProviders.contains(extensionId); + extensionId != null && !isBuiltInMetadataProvider(extensionId); final resolvedExtensionId = extensionId; _pushViaPreferredNavigator( diff --git a/lib/utils/provider_ui_utils.dart b/lib/utils/provider_ui_utils.dart new file mode 100644 index 00000000..d883269d --- /dev/null +++ b/lib/utils/provider_ui_utils.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +IconData resolveProviderIcon( + String providerId, { + IconData tidalIcon = Icons.music_note, + IconData builtInDefaultIcon = Icons.album, + IconData deezerIcon = Icons.graphic_eq, + IconData fallbackIcon = Icons.extension, +}) { + final builtIn = builtInProviderSpecForId(providerId); + if (builtIn != null) { + if (providerId == 'tidal') { + return tidalIcon; + } + return builtInDefaultIcon; + } + + if (providerId == 'deezer') { + return deezerIcon; + } + + return fallbackIcon; +}