From 482457205aa5e60b68cdb3d1d0316127b9693c21 Mon Sep 17 00:00:00 2001
From: zarzet
Date: Wed, 7 Jan 2026 03:25:14 +0700
Subject: [PATCH] feat: Add Deezer as alternative metadata source with
auto-fallback
- Add Deezer API client (no auth required, rate limit per user IP)
- Add search source selector in Settings (Deezer/Spotify)
- Default to Deezer for better reliability
- Auto-fallback to Deezer when Spotify API is rate limited (429)
- Support fallback for tracks and albums via SongLink API
- Update README with metadata source documentation
- Version 2.1.5-preview (build 42)
---
CHANGELOG.md | 19 +
README.md | 28 +
.../kotlin/com/zarz/spotiflac/MainActivity.kt | 47 ++
go_backend/deezer.go | 591 ++++++++++++++++++
go_backend/exports.go | 197 ++++++
go_backend/songlink.go | 121 ++++
lib/constants/app_info.dart | 4 +-
lib/models/settings.dart | 4 +
lib/models/settings.g.dart | 2 +
lib/providers/settings_provider.dart | 5 +
lib/providers/track_provider.dart | 40 +-
lib/screens/home_screen.dart | 3 +-
lib/screens/home_tab.dart | 6 +-
lib/screens/search_screen.dart | 6 +-
.../settings/options_settings_page.dart | 121 +++-
lib/services/platform_bridge.dart | 48 ++
pubspec.yaml | 2 +-
17 files changed, 1211 insertions(+), 33 deletions(-)
create mode 100644 go_backend/deezer.go
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:
+
+[](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