mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a |
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>? ?? [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: 'none'
|
||||
version: 2.1.0+41
|
||||
version: 2.1.5+43
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user