diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b8016a..97c55e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2.1.5-preview] - 2026-01-07 + +### Added +- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search + - Configure in Settings > Options > Spotify API > Search Source + - Default is Deezer for better reliability + - Spotify URLs are always supported regardless of this setting +- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer + - Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID + - Fetches metadata from Deezer instead + - Works for tracks and albums (playlists are user-specific, artists require Spotify API) +- **Debug Logging for Search Source**: Console logs now show which metadata source is being used + - `[Search] Using metadata source: deezer/spotify for query: "..."` + - `[FetchURL] Fetching track with Deezer fallback enabled...` + +### Technical +- New settings field: `metadataSource` in `lib/models/settings.dart` +- New UI: Search Source selector in Options Settings page + ## [2.1.0] - 2026-01-06 ### Added diff --git a/README.md b/README.md index 5c242b25..44993875 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,34 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account

+## Metadata Source + +SpotiFLAC supports two metadata sources for searching tracks: + +| Source | Pros | Cons | +|--------|------|------| +| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog | +| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting | + +### Using Deezer (Recommended) +Works out of the box. No setup required. + +### Using Spotify +To use Spotify as your search source without hitting rate limits: +1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com) +2. Create an app to get your Client ID and Client Secret +3. Go to **Settings > Options > Spotify API > Custom Credentials** +4. Enter your Client ID and Secret +5. Change **Search Source** to Spotify + +> **Note**: Spotify URLs (track, album) are always supported regardless of your search source setting. The app will automatically fall back to Deezer if Spotify API is rate limited. + +## Support + +If you find this app useful, consider supporting the development: + +[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20Me-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/zarzet) + ## Other project ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) 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 e1a3890c..f22ca490 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -230,6 +230,53 @@ class MainActivity: FlutterActivity() { } result.success(null) } + // Deezer API methods + "searchDeezerAll" -> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 3 + val response = withContext(Dispatchers.IO) { + Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong()) + } + 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) + } + "parseDeezerUrl" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.parseDeezerURLExport(url) + } + result.success(response) + } + "searchDeezerByISRC" -> { + val isrc = call.argument("isrc") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchDeezerByISRC(isrc) + } + result.success(response) + } + "convertSpotifyToDeezer" -> { + val resourceType = call.argument("resource_type") ?: "" + val spotifyId = call.argument("spotify_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.convertSpotifyToDeezer(resourceType, spotifyId) + } + result.success(response) + } + "getSpotifyMetadataWithFallback" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getSpotifyMetadataWithDeezerFallback(url) + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/deezer.go b/go_backend/deezer.go new file mode 100644 index 00000000..c3eb879a --- /dev/null +++ b/go_backend/deezer.go @@ -0,0 +1,591 @@ +package gobackend + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + deezerSearchURL = "https://api.deezer.com/search" + deezerTrackURL = "https://api.deezer.com/track/%s" + deezerAlbumURL = "https://api.deezer.com/album/%s" + deezerArtistURL = "https://api.deezer.com/artist/%s" + deezerPlaylistURL = "https://api.deezer.com/playlist/%s" + + deezerCacheTTL = 10 * time.Minute +) + +// DeezerClient handles Deezer API interactions (no auth required) +type DeezerClient struct { + httpClient *http.Client + searchCache map[string]*cacheEntry + albumCache map[string]*cacheEntry + artistCache map[string]*cacheEntry + cacheMu sync.RWMutex +} + +// Singleton instance +var ( + deezerClient *DeezerClient + deezerClientOnce sync.Once +) + +// GetDeezerClient returns singleton Deezer client +func GetDeezerClient() *DeezerClient { + deezerClientOnce.Do(func() { + deezerClient = &DeezerClient{ + httpClient: NewHTTPClientWithTimeout(15 * time.Second), + searchCache: make(map[string]*cacheEntry), + albumCache: make(map[string]*cacheEntry), + artistCache: make(map[string]*cacheEntry), + } + }) + return deezerClient +} + +// Deezer API response types +type deezerTrack struct { + ID int64 `json:"id"` + Title string `json:"title"` + Duration int `json:"duration"` // in seconds + TrackPosition int `json:"track_position"` + DiskNumber int `json:"disk_number"` + ISRC string `json:"isrc"` + Link string `json:"link"` + Artist deezerArtist `json:"artist"` + Album deezerAlbumSimple `json:"album"` + Contributors []deezerArtist `json:"contributors"` +} + +type deezerArtist struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXL string `json:"picture_xl"` + NbFan int `json:"nb_fan"` +} + +type deezerAlbumSimple struct { + ID int64 `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` +} + +type deezerAlbumFull struct { + ID int64 `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + ReleaseDate string `json:"release_date"` + NbTracks int `json:"nb_tracks"` + Artist deezerArtist `json:"artist"` + Contributors []deezerArtist `json:"contributors"` + Tracks struct { + Data []deezerTrack `json:"data"` + } `json:"tracks"` +} + +type deezerArtistFull struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXL string `json:"picture_xl"` + NbFan int `json:"nb_fan"` + NbAlbum int `json:"nb_album"` +} + +type deezerPlaylistFull struct { + ID int64 `json:"id"` + Title string `json:"title"` + Picture string `json:"picture"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXL string `json:"picture_xl"` + NbTracks int `json:"nb_tracks"` + Creator struct { + Name string `json:"name"` + } `json:"creator"` + Tracks struct { + Data []deezerTrack `json:"data"` + } `json:"tracks"` +} + +// SearchAll searches for tracks and artists on Deezer +func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { + cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) + + c.cacheMu.RLock() + if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*SearchAllResult), nil + } + c.cacheMu.RUnlock() + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0), + Artists: make([]SearchArtistResult, 0), + } + + // Search tracks + trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) + var trackResp struct { + Data []deezerTrack `json:"data"` + } + if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { + return nil, fmt.Errorf("deezer track search failed: %w", err) + } + + for _, track := range trackResp.Data { + result.Tracks = append(result.Tracks, c.convertTrack(track)) + } + + // Search artists + artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) + var artistResp struct { + Data []deezerArtist `json:"data"` + } + if err := c.getJSON(ctx, artistURL, &artistResp); err == nil { + for _, artist := range artistResp.Data { + result.Artists = append(result.Artists, SearchArtistResult{ + ID: fmt.Sprintf("deezer:%d", artist.ID), + Name: artist.Name, + Images: c.getBestArtistImage(artist), + Followers: artist.NbFan, + Popularity: 0, + }) + } + } + + // Cache result + c.cacheMu.Lock() + c.searchCache[cacheKey] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(deezerCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil +} + +// GetTrack fetches a single track by Deezer ID +func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) { + trackURL := fmt.Sprintf(deezerTrackURL, trackID) + + var track deezerTrack + if err := c.getJSON(ctx, trackURL, &track); err != nil { + return nil, err + } + + return &TrackResponse{ + Track: c.convertTrack(track), + }, nil +} + +// GetAlbum fetches album with tracks +func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { + c.cacheMu.RLock() + if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*AlbumResponsePayload), nil + } + c.cacheMu.RUnlock() + + albumURL := fmt.Sprintf(deezerAlbumURL, albumID) + + var album deezerAlbumFull + if err := c.getJSON(ctx, albumURL, &album); err != nil { + return nil, err + } + + albumImage := c.getBestAlbumImage(album) + artistName := album.Artist.Name + if len(album.Contributors) > 0 { + names := make([]string, len(album.Contributors)) + for i, a := range album.Contributors { + names[i] = a.Name + } + artistName = strings.Join(names, ", ") + } + + info := AlbumInfoMetadata{ + TotalTracks: album.NbTracks, + Name: album.Title, + ReleaseDate: album.ReleaseDate, + Artists: artistName, + Images: albumImage, + } + + tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data)) + for _, track := range album.Tracks.Data { + // Need to fetch full track info for ISRC + fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID)) + isrc := "" + if fullTrack != nil { + isrc = fullTrack.ISRC + } + + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: fmt.Sprintf("deezer:%d", track.ID), + Artists: track.Artist.Name, + Name: track.Title, + AlbumName: album.Title, + AlbumArtist: artistName, + DurationMS: track.Duration * 1000, + Images: albumImage, + ReleaseDate: album.ReleaseDate, + TrackNumber: track.TrackPosition, + TotalTracks: album.NbTracks, + DiscNumber: track.DiskNumber, + ExternalURL: track.Link, + ISRC: isrc, + AlbumID: fmt.Sprintf("deezer:%d", album.ID), + }) + } + + result := &AlbumResponsePayload{ + AlbumInfo: info, + TrackList: tracks, + } + + c.cacheMu.Lock() + c.albumCache[albumID] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(deezerCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil +} + +// GetArtist fetches artist with albums +func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) { + c.cacheMu.RLock() + if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*ArtistResponsePayload), nil + } + c.cacheMu.RUnlock() + + // Fetch artist info + artistURL := fmt.Sprintf(deezerArtistURL, artistID) + var artist deezerArtistFull + if err := c.getJSON(ctx, artistURL, &artist); err != nil { + return nil, err + } + + artistInfo := ArtistInfoMetadata{ + ID: fmt.Sprintf("deezer:%d", artist.ID), + Name: artist.Name, + Images: c.getBestArtistImageFull(artist), + Followers: artist.NbFan, + Popularity: 0, + } + + // Fetch artist albums + albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) + var albumsResp struct { + Data []struct { + ID int64 `json:"id"` + Title string `json:"title"` + ReleaseDate string `json:"release_date"` + NbTracks int `json:"nb_tracks"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + RecordType string `json:"record_type"` // album, single, ep, compile + } `json:"data"` + } + + albums := make([]ArtistAlbumMetadata, 0) + if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil { + for _, album := range albumsResp.Data { + albumType := album.RecordType + if albumType == "compile" { + albumType = "compilation" + } + + coverURL := album.CoverXL + if coverURL == "" { + coverURL = album.CoverBig + } + if coverURL == "" { + coverURL = album.CoverMedium + } + if coverURL == "" { + coverURL = album.Cover + } + + albums = append(albums, ArtistAlbumMetadata{ + ID: fmt.Sprintf("deezer:%d", album.ID), + Name: album.Title, + ReleaseDate: album.ReleaseDate, + TotalTracks: album.NbTracks, + Images: coverURL, + AlbumType: albumType, + Artists: artist.Name, + }) + } + } + + result := &ArtistResponsePayload{ + ArtistInfo: artistInfo, + Albums: albums, + } + + c.cacheMu.Lock() + c.artistCache[artistID] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(deezerCacheTTL), + } + c.cacheMu.Unlock() + + return result, nil +} + +// GetPlaylist fetches playlist with tracks +func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) { + playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID) + + var playlist deezerPlaylistFull + if err := c.getJSON(ctx, playlistURL, &playlist); err != nil { + return nil, err + } + + playlistImage := playlist.PictureXL + if playlistImage == "" { + playlistImage = playlist.PictureBig + } + if playlistImage == "" { + playlistImage = playlist.PictureMedium + } + + var info PlaylistInfoMetadata + info.Tracks.Total = playlist.NbTracks + info.Owner.DisplayName = playlist.Creator.Name + info.Owner.Name = playlist.Title + info.Owner.Images = playlistImage + + tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data)) + for _, track := range playlist.Tracks.Data { + albumImage := track.Album.CoverXL + if albumImage == "" { + albumImage = track.Album.CoverBig + } + if albumImage == "" { + albumImage = track.Album.CoverMedium + } + + // Fetch full track for ISRC + fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID)) + isrc := "" + releaseDate := "" + if fullTrack != nil { + isrc = fullTrack.ISRC + } + + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: fmt.Sprintf("deezer:%d", track.ID), + Artists: track.Artist.Name, + Name: track.Title, + AlbumName: track.Album.Title, + AlbumArtist: track.Artist.Name, + DurationMS: track.Duration * 1000, + Images: albumImage, + ReleaseDate: releaseDate, + TrackNumber: track.TrackPosition, + DiscNumber: track.DiskNumber, + ExternalURL: track.Link, + ISRC: isrc, + AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID), + }) + } + + return &PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + }, nil +} + +// SearchByISRC searches for a track by ISRC +func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) { + searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc) + + var resp struct { + Data []deezerTrack `json:"data"` + } + if err := c.getJSON(ctx, searchURL, &resp); err != nil { + return nil, err + } + + if len(resp.Data) == 0 { + return nil, fmt.Errorf("no track found for ISRC: %s", isrc) + } + + track := c.convertTrack(resp.Data[0]) + return &track, nil +} + +func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) { + trackURL := fmt.Sprintf(deezerTrackURL, trackID) + var track deezerTrack + if err := c.getJSON(ctx, trackURL, &track); err != nil { + return nil, err + } + return &track, nil +} + +func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { + artistName := track.Artist.Name + if len(track.Contributors) > 0 { + names := make([]string, len(track.Contributors)) + for i, a := range track.Contributors { + names[i] = a.Name + } + artistName = strings.Join(names, ", ") + } + + albumImage := track.Album.CoverXL + if albumImage == "" { + albumImage = track.Album.CoverBig + } + if albumImage == "" { + albumImage = track.Album.CoverMedium + } + if albumImage == "" { + albumImage = track.Album.Cover + } + + return TrackMetadata{ + SpotifyID: fmt.Sprintf("deezer:%d", track.ID), + Artists: artistName, + Name: track.Title, + AlbumName: track.Album.Title, + AlbumArtist: track.Artist.Name, + DurationMS: track.Duration * 1000, + Images: albumImage, + TrackNumber: track.TrackPosition, + DiscNumber: track.DiskNumber, + ExternalURL: track.Link, + ISRC: track.ISRC, + } +} + +func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string { + if artist.PictureXL != "" { + return artist.PictureXL + } + if artist.PictureBig != "" { + return artist.PictureBig + } + if artist.PictureMedium != "" { + return artist.PictureMedium + } + return artist.Picture +} + +func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string { + if artist.PictureXL != "" { + return artist.PictureXL + } + if artist.PictureBig != "" { + return artist.PictureBig + } + if artist.PictureMedium != "" { + return artist.PictureMedium + } + return artist.Picture +} + +func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { + if album.CoverXL != "" { + return album.CoverXL + } + if album.CoverBig != "" { + return album.CoverBig + } + if album.CoverMedium != "" { + return album.CoverMedium + } + return album.Cover +} + +func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body)) + } + + return json.Unmarshal(body, dst) +} + +// parseDeezerURL is internal function, returns type and ID +func parseDeezerURL(input string) (string, string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "", fmt.Errorf("empty URL") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", "", err + } + + if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" { + return "", "", fmt.Errorf("not a Deezer URL") + } + + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + + // Skip language prefix if present (e.g., /en/, /fr/) + if len(parts) > 0 && len(parts[0]) == 2 { + parts = parts[1:] + } + + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid Deezer URL format") + } + + resourceType := parts[0] + resourceID := parts[1] + + switch resourceType { + case "track", "album", "artist", "playlist": + return resourceType, resourceID, nil + default: + return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) + } +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 0ecf19d4..1a22f180 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -566,6 +566,203 @@ func ClearTrackIDCache() { ClearTrackCache() } +// ==================== DEEZER API ==================== + +// SearchDeezerAll searches for tracks and artists on Deezer (no API key required) +// Returns JSON with tracks and artists arrays +func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := GetDeezerClient() + results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetDeezerMetadata fetches metadata from Deezer URL or ID +// resourceType: track, album, artist, playlist +// resourceID: Deezer ID +func GetDeezerMetadata(resourceType, resourceID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := GetDeezerClient() + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = client.GetTrack(ctx, resourceID) + case "album": + data, err = client.GetAlbum(ctx, resourceID) + case "artist": + data, err = client.GetArtist(ctx, resourceID) + case "playlist": + data, err = client.GetPlaylist(ctx, resourceID) + default: + return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) + } + + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ParseDeezerURLExport parses a Deezer URL and returns type and ID +func ParseDeezerURLExport(url string) (string, error) { + resourceType, resourceID, err := parseDeezerURL(url) + if err != nil { + return "", err + } + + result := map[string]string{ + "type": resourceType, + "id": resourceID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SearchDeezerByISRC searches for a track by ISRC on Deezer +func SearchDeezerByISRC(isrc string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client := GetDeezerClient() + track, err := client.SearchByISRC(ctx, isrc) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(track) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata +// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer +// Useful when Spotify API is rate limited +func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + songlink := NewSongLinkClient() + deezerClient := GetDeezerClient() + + // For tracks, we can use SongLink to get Deezer ID + if resourceType == "track" { + deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) + if err != nil { + return "", fmt.Errorf("could not find Deezer equivalent: %w", err) + } + + // Fetch metadata from Deezer + trackResp, err := deezerClient.GetTrack(ctx, deezerID) + if err != nil { + return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) + } + + jsonBytes, err := json.Marshal(trackResp) + if err != nil { + return "", err + } + + return string(jsonBytes), nil + } + + // For albums, SongLink also provides mapping + if resourceType == "album" { + deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) + if err != nil { + return "", fmt.Errorf("could not find Deezer album: %w", err) + } + + // Fetch album metadata from Deezer + albumResp, err := deezerClient.GetAlbum(ctx, deezerID) + if err != nil { + return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) + } + + jsonBytes, err := json.Marshal(albumResp) + if err != nil { + return "", err + } + + return string(jsonBytes), nil + } + + // For artists/playlists, SongLink doesn't provide direct mapping + return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) +} + +// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit +func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Try Spotify first + client := NewSpotifyMetadataClient() + data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) + if err == nil { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + // Check if it's a rate limit error + errStr := strings.ToLower(err.Error()) + if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { + // Not a rate limit error, return original error + return "", err + } + + // Rate limited - try Deezer fallback for tracks and albums + parsed, parseErr := parseSpotifyURI(spotifyURL) + if parseErr != nil { + return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) + } + + fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) + + if parsed.Type == "track" || parsed.Type == "album" { + // Convert to Deezer + return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) + } + + // Artist and playlist not supported for fallback + if parsed.Type == "artist" { + return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later") + } + + return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") +} + func errorResponse(msg string) (string, error) { resp := DownloadResponse{ Success: false, diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 32d4d05d..1192f34d 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "sync" "time" ) @@ -21,9 +22,12 @@ type TrackAvailability struct { Tidal bool `json:"tidal"` Amazon bool `json:"amazon"` Qobuz bool `json:"qobuz"` + Deezer bool `json:"deezer"` TidalURL string `json:"tidal_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"` + DeezerURL string `json:"deezer_url,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` } var ( @@ -102,6 +106,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.AmazonURL = amazonLink.URL } + // Check Deezer + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + availability.Deezer = true + availability.DeezerURL = deezerLink.URL + // Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456) + availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) + } + // Check Qobuz using ISRC if isrc != "" { availability.Qobuz = checkQobuzAvailability(isrc) @@ -161,3 +173,112 @@ func checkQobuzAvailability(isrc string) bool { return searchResp.Tracks.Total > 0 } + +// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL +func extractDeezerIDFromURL(deezerURL string) string { + // URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456 + parts := strings.Split(deezerURL, "/") + if len(parts) > 0 { + // Get the last part which should be the ID + lastPart := parts[len(parts)-1] + // Remove any query parameters + if idx := strings.Index(lastPart, "?"); idx > 0 { + lastPart = lastPart[:idx] + } + return lastPart + } + return "" +} + +// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink +func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { + availability, err := s.CheckTrackAvailability(spotifyTrackID, "") + if err != nil { + return "", err + } + + if !availability.Deezer || availability.DeezerID == "" { + return "", fmt.Errorf("track not found on Deezer") + } + + return availability.DeezerID, nil +} + +// AlbumAvailability represents album availability on different platforms +type AlbumAvailability struct { + SpotifyID string `json:"spotify_id"` + Deezer bool `json:"deezer"` + DeezerURL string `json:"deezer_url,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` +} + +// CheckAlbumAvailability checks album availability on streaming platforms using SongLink +func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { + // Use global rate limiter + songLinkRateLimiter.WaitForSlot() + + // Build API URL for album + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==") + spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID) + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + retryConfig := DefaultRetryConfig() + resp, err := DoRequestWithRetry(s.client, req, retryConfig) + if err != nil { + return nil, fmt.Errorf("failed to check album availability: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + body, err := ReadResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + + if err := json.Unmarshal(body, &songLinkResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + availability := &AlbumAvailability{ + SpotifyID: spotifyAlbumID, + } + + // Check Deezer + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + availability.Deezer = true + availability.DeezerURL = deezerLink.URL + availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) + } + + return availability, nil +} + +// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink +func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) { + availability, err := s.CheckAlbumAvailability(spotifyAlbumID) + if err != nil { + return "", err + } + + if !availability.Deezer || availability.DeezerID == "" { + return "", fmt.Errorf("album not found on Deezer") + } + + return availability.DeezerID, nil +} diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 5d353551..f919f177 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.1.0'; - static const String buildNumber = '41'; + static const String version = '2.1.5-preview'; + static const String buildNumber = '42'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 4f5d9aed..cad0de26 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -22,6 +22,7 @@ class AppSettings { final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) + final String metadataSource; // spotify, deezer - source for search and metadata const AppSettings({ this.defaultService = 'tidal', @@ -42,6 +43,7 @@ class AppSettings { this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientSecret = '', // Default: use built-in credentials this.useCustomSpotifyCredentials = true, // Default: use custom if set + this.metadataSource = 'deezer', // Default: Deezer (no rate limit) }); AppSettings copyWith({ @@ -63,6 +65,7 @@ class AppSettings { String? spotifyClientId, String? spotifyClientSecret, bool? useCustomSpotifyCredentials, + String? metadataSource, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -83,6 +86,7 @@ class AppSettings { spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, + metadataSource: metadataSource ?? this.metadataSource, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 5d458693..240114fe 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -26,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', useCustomSpotifyCredentials: json['useCustomSpotifyCredentials'] as bool? ?? true, + metadataSource: json['metadataSource'] as String? ?? 'deezer', ); Map _$AppSettingsToJson(AppSettings instance) => @@ -48,4 +49,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'spotifyClientId': instance.spotifyClientId, 'spotifyClientSecret': instance.spotifyClientSecret, 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, + 'metadataSource': instance.metadataSource, }; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 17944142..9fadea1c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -156,6 +156,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); _applySpotifyCredentials(); } + + void setMetadataSource(String source) { + state = state.copyWith(metadataSource: source); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 89471ffa..bb2e5978 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -114,7 +114,7 @@ class TrackNotifier extends Notifier { /// Check if request is still valid (not cancelled by newer request) bool _isRequestValid(int requestId) => requestId == _currentRequestId; - Future fetchFromUrl(String url) async { + Future fetchFromUrl(String url, {bool useDeezerFallback = true}) async { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; @@ -127,7 +127,22 @@ class TrackNotifier extends Notifier { final type = parsed['type'] as String; - final metadata = await PlatformBridge.getSpotifyMetadata(url); + // Use the new fallback-enabled method + Map metadata; + + try { + // ignore: avoid_print + print('[FetchURL] Fetching $type with Deezer fallback enabled...'); + metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); + // ignore: avoid_print + print('[FetchURL] Metadata fetch success'); + } catch (e) { + // If fallback also fails, show error + // ignore: avoid_print + print('[FetchURL] Metadata fetch failed: $e'); + rethrow; + } + if (!_isRequestValid(requestId)) return; // Request cancelled if (type == 'track') { @@ -184,7 +199,7 @@ class TrackNotifier extends Notifier { } } - Future search(String query) async { + Future search(String query, {String? metadataSource}) async { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; @@ -192,7 +207,24 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { - final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); + // Use Deezer or Spotify based on settings + final source = metadataSource ?? 'deezer'; + + // Debug log to show which source is being used + // ignore: avoid_print + print('[Search] Using metadata source: $source for query: "$query"'); + + Map results; + if (source == 'deezer') { + results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); + // ignore: avoid_print + print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks'); + } else { + results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); + // ignore: avoid_print + print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks'); + } + if (!_isRequestValid(requestId)) return; // Request cancelled final trackList = results['tracks'] as List? ?? []; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e49bb20a..6950ae07 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState { if (url.startsWith('http') || url.startsWith('spotify:')) { await ref.read(trackProvider.notifier).fetchFromUrl(url); } else { - await ref.read(trackProvider.notifier).search(url); + final settings = ref.read(settingsProvider); + await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource); } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e33aef78..6b9ea7ab 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (_lastSearchQuery == query) return; _lastSearchQuery = query; - await ref.read(trackProvider.notifier).search(query); + final settings = ref.read(settingsProvider); + await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient await ref.read(trackProvider.notifier).fetchFromUrl(url); _navigateToDetailIfNeeded(); } else { - await ref.read(trackProvider.notifier).search(url); + final settings = ref.read(settingsProvider); + await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d775cf92..39789ddf 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState { _searchController = TextEditingController(text: widget.query); if (widget.query.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(trackProvider.notifier).search(widget.query); + final settings = ref.read(settingsProvider); + ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource); }); } } @@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState { void _search() { final query = _searchController.text.trim(); if (query.isNotEmpty) { - ref.read(trackProvider.notifier).search(query); + final settings = ref.read(settingsProvider); + ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); } } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 56da180d..908c4f65 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -119,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ - SettingsItem( - icon: Icons.key, - title: 'Custom Credentials', - subtitle: settings.spotifyClientId.isNotEmpty - ? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}' - : 'Not configured', - onTap: () => _showSpotifyCredentialsDialog(context, ref, settings), - trailing: settings.spotifyClientId.isNotEmpty - ? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20) - : Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20), - showDivider: settings.spotifyClientId.isNotEmpty, + _MetadataSourceSelector( + currentSource: settings.metadataSource, + onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v), ), - if (settings.spotifyClientId.isNotEmpty) - SettingsSwitchItem( - icon: Icons.toggle_on, - title: 'Use Custom Credentials', - subtitle: settings.useCustomSpotifyCredentials - ? 'Using your credentials' - : 'Using default credentials', - value: settings.useCustomSpotifyCredentials, - onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v), - showDivider: false, + if (settings.metadataSource == 'spotify') ...[ + SettingsItem( + icon: Icons.key, + title: 'Custom Credentials', + subtitle: settings.spotifyClientId.isNotEmpty + ? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}' + : 'Not configured', + onTap: () => _showSpotifyCredentialsDialog(context, ref, settings), + trailing: settings.spotifyClientId.isNotEmpty + ? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20) + : Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20), + showDivider: settings.spotifyClientId.isNotEmpty, ), + if (settings.spotifyClientId.isNotEmpty) + SettingsSwitchItem( + icon: Icons.toggle_on, + title: 'Use Custom Credentials', + subtitle: settings.useCustomSpotifyCredentials + ? 'Using your credentials' + : 'Using default credentials', + value: settings.useCustomSpotifyCredentials, + onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v), + showDivider: false, + ), + ], ], ), ), @@ -469,3 +475,76 @@ class _ChannelChip extends StatelessWidget { ); } } + +class _MetadataSourceSelector extends StatelessWidget { + final String currentSource; + final ValueChanged onChanged; + const _MetadataSourceSelector({required this.currentSource, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Search Source', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 2), + Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ])), + ]), + const SizedBox(height: 16), + Row(children: [ + _SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')), + const SizedBox(width: 8), + _SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')), + ]), + const SizedBox(height: 12), + Row(children: [ + Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded(child: Text('Spotify URLs are always supported regardless of this setting', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))), + ]), + ]), + ); + } +} + +class _SourceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + const _SourceChip({required this.label, required this.isSelected, required this.onTap}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final unselectedColor = isDark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) + : colorScheme.surfaceContainerHigh; + + return Expanded( + child: Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center(child: Text(label, style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))), + ), + ), + ), + ); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 5ee2da00..442158b2 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -316,4 +316,52 @@ class PlatformBridge { static Future clearTrackCache() async { await _channel.invokeMethod('clearTrackCache'); } + + // ==================== DEEZER API ==================== + + /// Search Deezer for tracks and artists (no API key required) + static Future> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { + final result = await _channel.invokeMethod('searchDeezerAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + }); + return jsonDecode(result as String) as Map; + } + + /// Get Deezer metadata by type and ID + static Future> getDeezerMetadata(String resourceType, String resourceId) async { + final result = await _channel.invokeMethod('getDeezerMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + return jsonDecode(result as String) as Map; + } + + /// Parse Deezer URL and return type and ID + static Future> parseDeezerUrl(String url) async { + final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + /// Search Deezer by ISRC + static Future> searchDeezerByISRC(String isrc) async { + final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); + return jsonDecode(result as String) as Map; + } + + /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) + static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { + final result = await _channel.invokeMethod('convertSpotifyToDeezer', { + 'resource_type': resourceType, + 'spotify_id': spotifyId, + }); + return jsonDecode(result as String) as Map; + } + + /// Get Spotify metadata with automatic Deezer fallback on rate limit + static Future> getSpotifyMetadataWithFallback(String url) async { + final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); + return jsonDecode(result as String) as Map; + } } diff --git a/pubspec.yaml b/pubspec.yaml index 67931423..a4a191f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.1.0+41 +version: 2.1.5-preview+42 environment: sdk: ^3.10.0