mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +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 }}-
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- 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
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# 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
|
## [2.1.0] - 2026-01-06
|
||||||
|
|
||||||
### Added
|
### 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" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</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
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## 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!
|
> **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)
|
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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
defer bufWriter.Flush()
|
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
// Use item progress writer with buffered output
|
||||||
var bytesWritten int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"qobuz", "tidal", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "qobuz"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
// Create ordered list: preferred first, then others
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
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
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
@@ -566,6 +571,203 @@ func ClearTrackIDCache() {
|
|||||||
ClearTrackCache()
|
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) {
|
func errorResponse(msg string) (string, error) {
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
+14
-15
@@ -195,29 +195,26 @@ func getDownloadDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
// Uses buffered writing for better performance
|
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
buffer []byte
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
bufPos int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
buffer: make([]byte, progressWriterBufferSize),
|
lastReported: 0,
|
||||||
bufPos: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with buffering
|
// Write implements io.Writer with threshold-based progress updates
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,9 +222,11 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
|
|
||||||
// Update progress less frequently (every 64KB) to reduce lock contention
|
// Update progress when we've received at least 64KB since last update
|
||||||
if pw.current%(64*1024) == 0 || pw.current == 0 {
|
// Also update on first write to show download has started
|
||||||
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
|
pw.lastReported = pw.current
|
||||||
}
|
}
|
||||||
return n, nil
|
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)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
defer bufWriter.Flush()
|
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// 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
|
// QobuzDownloadResult contains download result with quality info
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -21,9 +22,12 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -102,6 +106,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.AmazonURL = amazonLink.URL
|
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
|
// Check Qobuz using ISRC
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
@@ -161,3 +173,112 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
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)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
defer bufWriter.Flush()
|
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// 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 {
|
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)
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use item progress writer
|
// Use item progress writer
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
written, err = io.Copy(out, resp.Body)
|
||||||
_, 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
|
// DASH format - download segments to temporary file
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.1.0';
|
static const String version = '2.1.5';
|
||||||
static const String buildNumber = '41';
|
static const String buildNumber = '43';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ class AppSettings {
|
|||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'qobuz',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
@@ -42,6 +43,7 @@ class AppSettings {
|
|||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -63,6 +65,7 @@ class AppSettings {
|
|||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -83,6 +86,7 @@ class AppSettings {
|
|||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -48,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'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';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
@override
|
@override
|
||||||
@@ -18,11 +20,35 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
|
// Run migrations if needed
|
||||||
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
// Apply Spotify credentials to Go backend on load
|
||||||
_applySpotifyCredentials();
|
_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 {
|
Future<void> _saveSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
@@ -156,6 +182,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setMetadataSource(String source) {
|
||||||
|
state = state.copyWith(metadataSource: source);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
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)
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
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
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
final type = parsed['type'] as String;
|
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 (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
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
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -192,7 +207,24 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
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
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
|
|||||||
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
Map<String, dynamic> metadata;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
|
// 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 trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
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 {
|
Future<void> _fetchDiscography() async {
|
||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
List<ArtistAlbum> albums;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
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
|
// Store in cache
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
_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),
|
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
} else {
|
} 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;
|
if (_lastSearchQuery == query) return;
|
||||||
_lastSearchQuery = query;
|
_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();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
_navigateToDetailIfNeeded();
|
_navigateToDetailIfNeeded();
|
||||||
} else {
|
} 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();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
_searchController = TextEditingController(text: widget.query);
|
_searchController = TextEditingController(text: widget.query);
|
||||||
if (widget.query.isNotEmpty) {
|
if (widget.query.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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() {
|
void _search() {
|
||||||
final query = _searchController.text.trim();
|
final query = _searchController.text.trim();
|
||||||
if (query.isNotEmpty) {
|
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(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_MetadataSourceSelector(
|
||||||
icon: Icons.key,
|
currentSource: settings.metadataSource,
|
||||||
title: 'Custom Credentials',
|
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
|
||||||
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)
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
SettingsSwitchItem(
|
SettingsItem(
|
||||||
icon: Icons.toggle_on,
|
icon: Icons.key,
|
||||||
title: 'Use Custom Credentials',
|
title: 'Custom Credentials',
|
||||||
subtitle: settings.useCustomSpotifyCredentials
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Using your credentials'
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
: 'Using default credentials',
|
: 'Not configured',
|
||||||
value: settings.useCustomSpotifyCredentials,
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
showDivider: false,
|
? 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 discNumber = 1,
|
||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'tidal',
|
String preferredService = 'qobuz',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -316,4 +316,52 @@ class PlatformBridge {
|
|||||||
static Future<void> clearTrackCache() async {
|
static Future<void> clearTrackCache() async {
|
||||||
await _channel.invokeMethod('clearTrackCache');
|
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
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.1.0+41
|
version: 2.1.5+43
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user