Compare commits

..

12 Commits

Author SHA1 Message Date
zarzet e9c7bf830e Update changelog for v2.1.5 2026-01-08 00:53:38 +07:00
zarzet 8bc97d5bd3 v2.1.5: Deezer API 2.0, Qobuz default, fetch ISRC for search results 2026-01-08 00:52:24 +07:00
zarzet f2c241c323 Fix .tmp permission issue on Android Music folder 2026-01-08 00:26:49 +07:00
zarzet 9c512ffe28 Add migration for Deezer default (skip if custom Spotify enabled) 2026-01-07 23:19:04 +07:00
zarzet 53a1da6249 v2.1.5: Fix progress bar and incomplete downloads
- Fix progress bar jumping from 1% to 100% (threshold-based updates)
- Fix incomplete downloads with temp file + size validation
- Applies to Tidal, Qobuz, and Amazon services
2026-01-07 23:15:48 +07:00
Zarz Eleutherius d4274e8ca8 Include setup instructions for Spotify API usage
Added detailed instructions for setting up Spotify as a search source, including steps for creating a developer account and entering credentials.
2026-01-07 13:55:31 +07:00
Zarz Eleutherius 49a9f12841 Simplify README by removing Spotify setup details
Removed detailed instructions for using Spotify and support section.
2026-01-07 13:53:30 +07:00
zarzet d7fa040e3c fix: Deezer artist/album screen improvements
- Fix album_screen to support Deezer album IDs (deezer:xxx format)
- Use getSpotifyMetadataWithFallback for Spotify albums
- Hide track count in artist discography when not available from Deezer API
- Deezer /artist/{id}/albums endpoint doesn't return nb_tracks
2026-01-07 04:25:35 +07:00
zarzet 9baa1e2088 fix: Replace android-actions/setup-android with direct SDK setup 2026-01-07 03:38:00 +07:00
zarzet 482457205a 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)
2026-01-07 03:25:14 +07:00
zarzet 3b2ec319e2 Fix pubspec version to 2.1.0+41 2026-01-07 00:19:53 +07:00
zarzet a0f7e75a9a Update VirusTotal badge to 2.1.0 2026-01-07 00:15:43 +07:00
24 changed files with 1463 additions and 94 deletions
+13 -1
View File
@@ -85,7 +85,19 @@ jobs:
restore-keys: gradle-${{ runner.os }}-
- name: Install Android SDK & NDK
uses: android-actions/setup-android@v3
run: |
# Use pre-installed Android SDK on GitHub runners
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
# Accept licenses
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
# Install NDK (required for gomobile)
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
# Set NDK path
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
- name: Install gomobile
run: |
+37
View File
@@ -1,5 +1,42 @@
# Changelog
## [2.1.5] - 2026-01-08
### 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)
### Changed
- **Default Download Service**: Changed from Tidal to Qobuz
- Fallback order is now: Qobuz → Tidal → Amazon
- **Deezer API Updated to v2.0**: More reliable and complete metadata
- Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
- Search results now fetch full track info to include ISRC
### Fixed
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
- Progress now updates smoothly every 64KB of data received
- First progress update happens immediately when download starts
- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files
- File size is validated against server's Content-Length header
- Incomplete files are automatically deleted and error is reported
- Applies to all services: Tidal, Qobuz, and Amazon
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
- Improves track matching accuracy when downloading
### Technical
- New settings field: `metadataSource` in `lib/models/settings.dart`
- New UI: Search Source selector in Options Settings page
- Improved `ItemProgressWriter` with threshold-based progress updates
- Download functions now properly handle network interruptions
- Deezer API base URL changed to `https://api.deezer.com/2.0`
## [2.1.0] - 2026-01-06
### Added
+20 -1
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/b715b67c1a027823d622a6e578e668c855dee1c364726895a7c379f080a0b987)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
<div align="center">
@@ -23,11 +23,30 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## 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 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
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
[![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)
## Disclaimer
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
@@ -230,6 +230,53 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
// Deezer API methods
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
}
result.success(response)
}
"getDeezerMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseDeezerURLExport(url)
}
result.success(response)
}
"searchDeezerByISRC" -> {
val isrc = call.argument<String>("isrc") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchDeezerByISRC(isrc)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
}
result.success(response)
}
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+31 -11
View File
@@ -294,35 +294,55 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
var bytesWritten int64
var written int64
if itemID != "" {
pw := NewItemProgressWriter(bufWriter, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
written, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(bufWriter, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
written, err = io.Copy(bufWriter, resp.Body)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
return nil
}
+612
View File
@@ -0,0 +1,612 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
deezerBaseURL = "https://api.deezer.com/2.0"
deezerSearchURL = deezerBaseURL + "/search"
deezerTrackURL = deezerBaseURL + "/track/%s"
deezerAlbumURL = deezerBaseURL + "/album/%s"
deezerArtistURL = deezerBaseURL + "/artist/%s"
deezerPlaylistURL = deezerBaseURL + "/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 {
// Fetch full track info to get ISRC (search results don't include ISRC)
fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
if err == nil && fullTrack != nil {
result.Tracks = append(result.Tracks, c.convertTrack(*fullTrack))
} else {
// Fallback to search result without ISRC
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 using direct endpoint
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
// Use direct ISRC endpoint (API 2.0)
// https://api.deezer.com/2.0/track/isrc:{ISRC}
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
var track deezerTrack
if err := c.getJSON(ctx, directURL, &track); err != nil {
// Fallback to search if direct endpoint fails
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)
}
result := c.convertTrack(resp.Data[0])
return &result, nil
}
// Check if we got a valid response (ID > 0)
if track.ID == 0 {
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
}
result := c.convertTrack(track)
return &result, 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)
}
}
+204 -2
View File
@@ -276,12 +276,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
req.OutputDir = strings.TrimSpace(req.OutputDir)
// Build service order starting with preferred service
allServices := []string{"tidal", "qobuz", "amazon"}
allServices := []string{"qobuz", "tidal", "amazon"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
preferredService = "qobuz"
}
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
// Create ordered list: preferred first, then others
services := []string{preferredService}
for _, s := range allServices {
@@ -290,9 +292,12 @@ func DownloadWithFallback(requestJSON string) (string, error) {
}
}
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
var lastErr error
for _, service := range services {
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service
var result DownloadResult
@@ -566,6 +571,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,
+15 -16
View File
@@ -195,39 +195,38 @@ func getDownloadDir() string {
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
// Uses buffered writing for better performance
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
buffer []byte
bufPos int
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
lastReported int64 // Track last reported bytes for threshold-based updates
}
const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 0,
buffer: make([]byte, progressWriterBufferSize),
bufPos: 0,
writer: w,
itemID: itemID,
current: 0,
lastReported: 0,
}
}
// Write implements io.Writer with buffering
// Write implements io.Writer with threshold-based progress updates
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
// Update progress less frequently (every 64KB) to reduce lock contention
if pw.current%(64*1024) == 0 || pw.current == 0 {
// Update progress when we've received at least 64KB since last update
// Also update on first write to show download has started
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
SetItemBytesReceived(pw.itemID, pw.current)
pw.lastReported = pw.current
}
return n, nil
}
+32 -7
View File
@@ -473,30 +473,55 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(bufWriter, resp.Body)
written, err = io.Copy(bufWriter, resp.Body)
}
return err
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
// QobuzDownloadResult contains download result with quality info
+121
View File
@@ -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
}
+57 -14
View File
@@ -746,30 +746,55 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes if available
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
defer out.Close()
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(bufWriter, resp.Body)
written, err = io.Copy(bufWriter, resp.Body)
}
return err
// Flush buffer before checking for errors
flushErr := bufWriter.Flush()
closeErr := out.Close()
// Check for any errors
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
@@ -805,26 +830,44 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
// Set total bytes for progress tracking
if resp.ContentLength > 0 && itemID != "" {
SetItemBytesTotal(itemID, resp.ContentLength)
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
// Use item progress writer
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
_, err = io.Copy(progressWriter, resp.Body)
written, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
written, err = io.Copy(out, resp.Body)
}
return err
closeErr := out.Close()
if err != nil {
os.Remove(outputPath)
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
os.Remove(outputPath)
return fmt.Errorf("failed to close file: %w", closeErr)
}
// Verify file size if Content-Length was provided
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
// DASH format - download segments to temporary file
+2 -2
View File
@@ -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';
static const String buildNumber = '43';
static const String fullVersion = '$version+$buildNumber';
+5 -1
View File
@@ -22,9 +22,10 @@ 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',
this.defaultService = 'qobuz',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
@@ -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,
);
}
+2
View File
@@ -26,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -48,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
};
+31
View File
@@ -5,6 +5,8 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
class SettingsNotifier extends Notifier<AppSettings> {
@override
@@ -18,11 +20,35 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
// Run migrations if needed
await _runMigrations(prefs);
// Apply Spotify credentials to Go backend on load
_applySpotifyCredentials();
}
}
/// Run one-time migrations for settings
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
// Migration 1: Set metadataSource to 'deezer' for existing users
// Only apply if user hasn't enabled custom Spotify credentials
// (users with custom credentials likely prefer Spotify)
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
// Save current migration version
if (lastMigration < _currentMigrationVersion) {
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
}
}
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
@@ -156,6 +182,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+36 -4
View File
@@ -114,7 +114,7 @@ class TrackNotifier extends Notifier<TrackState> {
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url) async {
Future<void> 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<TrackState> {
final type = parsed['type'] as String;
final metadata = await PlatformBridge.getSpotifyMetadata(url);
// Use the new fallback-enabled method
Map<String, dynamic> 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<TrackState> {
}
}
Future<void> search(String query) async {
Future<void> 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<TrackState> {
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<String, dynamic> 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<dynamic>? ?? [];
+16 -2
View File
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
Map<String, dynamic> metadata;
// Check if this is a Deezer album ID (format: "deezer:123456")
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
} else {
// Spotify album - use fallback method
// ignore: avoid_print
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
+22 -5
View File
@@ -69,10 +69,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
List<ArtistAlbum> albums;
// Check if this is a Deezer artist ID (format: "deezer:123456")
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
// ignore: avoid_print
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
} else {
// Spotify artist - use fallback method
// ignore: avoid_print
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
}
// Store in cache
_ArtistCache.set(widget.artistId, albums);
@@ -290,7 +305,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
const Spacer(),
Text(
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks',
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate}${album.totalTracks} tracks'
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+2 -1
View File
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
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);
}
}
+4 -2
View File
@@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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();
}
+4 -2
View File
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_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<SearchScreen> {
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);
}
}
+100 -21
View File
@@ -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<String> 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))),
),
),
),
);
}
}
+49 -1
View File
@@ -111,7 +111,7 @@ class PlatformBridge {
int discNumber = 1,
int totalTracks = 1,
String? releaseDate,
String preferredService = 'tidal',
String preferredService = 'qobuz',
String? itemId,
int durationMs = 0,
}) async {
@@ -316,4 +316,52 @@ class PlatformBridge {
static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache');
}
// ==================== DEEZER API ====================
/// Search Deezer for tracks and artists (no API key required)
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// Get Deezer metadata by type and ID
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// Parse Deezer URL and return type and ID
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Search Deezer by ISRC
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// Get Spotify metadata with automatic Deezer fallback on rate limit
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
}
+1 -1
View File
@@ -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-preview2+40
version: 2.1.5+43
environment:
sdk: ^3.10.0