Compare commits

..

2 Commits

Author SHA1 Message Date
zarzet bea5dd1d4a v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search 2026-01-10 04:33:05 +07:00
Zarz Eleutherius 8726a0858a Update VirusTotal link in README.md 2026-01-09 19:03:36 +07:00
17 changed files with 622 additions and 313 deletions
+2 -5
View File
@@ -382,20 +382,17 @@ jobs:
### Downloads ### Downloads
#### Android #### Android
![Android arm64 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=for-the-badge&logo=android&label=arm64&color=3DDC84)
![Android arm32 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=for-the-badge&logo=android&label=arm32&color=3DDC84)
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices) - **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices) - **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
#### iOS #### iOS
![iOS Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=for-the-badge&logo=apple&label=iOS&color=0078D6)
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required) - **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
### Installation ### Installation
**Android**: Enable "Install from unknown sources" and install the APK **Android**: Enable "Install from unknown sources" and install the APK
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
FOOTER FOOTER
echo "Release body:" echo "Release body:"
+58
View File
@@ -1,5 +1,63 @@
# Changelog # Changelog
## [2.2.0] - 2026-01-10
### Fixed
- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
- Added proper null checks when parsing Go backend response
- Added type checking for track data before parsing
- **Duration Calculation in Enrichment**: Fixed duration conversion bug
- Go backend returns `duration_ms` (milliseconds)
- Now properly converts to seconds for Track model
### Changed
- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
- Tidal is now the default download service (was Qobuz)
- Tidal has faster and more reliable ISRC matching
- Existing users need to change setting manually or clear app data
- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
### Added
- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
- Returns as soon as ISRC match is found in first query results
- Significantly faster for tracks with valid ISRC
- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
- Search results don't include ISRC (for performance)
- ISRC is now fetched via metadata enrichment when download starts
- Ensures accurate track matching on all streaming services
- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
- Shows when ISRC search is attempted
- Shows number of results and exact ISRC matches found
### Technical
- Updated `go_backend/tidal.go`:
- Early exit optimization in `SearchTrackByMetadataWithISRC()`
- Deezer ID support in SongLink lookup
- Updated `go_backend/qobuz.go`:
- Added logging for ISRC search flow
- Duration tolerance reduced from 30s to 10s
- Updated `go_backend/exports.go`:
- Default service order changed to `[tidal, qobuz, amazon]`
- Updated `lib/providers/download_queue_provider.dart`:
- ISRC-based enrichment condition
- Null-safe parsing of Go backend response
- Updated `lib/services/platform_bridge.dart`:
- Null check for `getDeezerMetadata` result
- Updated `lib/models/settings.dart`:
- Default service changed to `tidal`
---
## [2.1.7] - 2026-01-09 ## [2.1.7] - 2026-01-09
### Added ### Added
+1 -1
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cb87d23fc9fd4a6f0a7b2b934773029c7e7ab25101dfce503e68e9c9e8921fca/)
<div align="center"> <div align="center">
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
} }
result.success(null) result.success(null)
} }
"readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath)
}
result.success(response)
}
"startDownloadService" -> { "startDownloadService" -> {
val trackName = call.argument<String>("track_name") ?: "" val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: "" val artistName = call.argument<String>("artist_name") ?: ""
+65 -33
View File
@@ -18,7 +18,7 @@ import (
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) // AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct { type AmazonDownloader struct {
client *http.Client client *http.Client
regions []string // us, eu regions for DoubleDouble service regions []string // us, eu regions for DoubleDouble service
lastAPICallTime time.Time // Rate limiting: track last API call lastAPICallTime time.Time // Rate limiting: track last API call
apiCallCount int // Rate limiting: counter per minute apiCallCount int // Rate limiting: counter per minute
apiCallResetTime time.Time // Rate limiting: reset time apiCallResetTime time.Time // Rate limiting: reset time
@@ -52,37 +52,37 @@ type DoubleDoubleStatusResponse struct {
func amazonArtistsMatch(expectedArtist, foundArtist string) bool { func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match // Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other // Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Check first artist (before comma or feat) // Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst) expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0] foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst) foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst { if expectedFirst == foundFirst {
return true return true
} }
// Check if first artist is contained in the other // Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true return true
} }
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration // assume they're the same artist with different transliteration
expectedASCII := amazonIsASCIIString(expectedArtist) expectedASCII := amazonIsASCIIString(expectedArtist)
@@ -91,7 +91,7 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true return true
} }
return false return false
} }
@@ -124,7 +124,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
defer amazonRateLimitMu.Unlock() defer amazonRateLimitMu.Unlock()
now := time.Now() now := time.Now()
// Reset counter every minute // Reset counter every minute
if now.Sub(a.apiCallResetTime) >= time.Minute { if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0 a.apiCallCount = 0
@@ -170,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
return apis return apis
} }
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) // downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
// This uses submit → poll → download mechanism // This uses submit → poll → download mechanism
// Internal function - not exported to gomobile // Internal function - not exported to gomobile
@@ -182,7 +181,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
// Build base URL for DoubleDouble service // Build base URL for DoubleDouble service
// Decode base64 service URL (same as PC) // Decode base64 service URL (same as PC)
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
@@ -202,7 +201,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...") fmt.Println("[Amazon] Submitting download request...")
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait) // Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
var resp *http.Response var resp *http.Response
maxRetries := 3 maxRetries := 3
@@ -345,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
} }
// DownloadFile downloads a file from URL with User-Agent and progress tracking // DownloadFile downloads a file from URL with User-Agent and progress tracking
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Initialize item progress (required for all downloads) // Initialize item progress (required for all downloads)
@@ -434,6 +432,7 @@ type AmazonDownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// downloadFromAmazon downloads a track using the request parameters // downloadFromAmazon downloads a track using the request parameters
@@ -450,7 +449,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
var availability *TrackAvailability var availability *TrackAvailability
var err error var err error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx") // Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") { if strings.HasPrefix(req.SpotifyID, "deezer:") {
// Extract Deezer ID and use Deezer-based lookup // Extract Deezer ID and use Deezer-based lookup
@@ -463,7 +462,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} else { } else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
} }
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
} }
@@ -546,16 +545,35 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
} }
// Read existing metadata from downloaded file BEFORE embedding
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
if metaErr == nil && existingMeta != nil {
// Use file metadata if it has valid track/disc numbers and request doesn't have them
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
fmt.Printf("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
fmt.Printf("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
}
// Embed metadata using Spotify data (more accurate than DoubleDouble) // Embed metadata using Spotify data (more accurate than DoubleDouble)
// But preserve track/disc numbers from file if they were better
metadata := Metadata{ metadata := Metadata{
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: actualTrackNum,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
} }
@@ -583,36 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
// Read actual quality from the downloaded FLAC file // Read actual quality from the downloaded FLAC file
// Amazon API doesn't provide quality info, but we can read it from the file itself // Amazon API doesn't provide quality info, but we can read it from the file itself
quality, err := GetAudioQuality(outputPath) quality, err := GetAudioQuality(outputPath)
if err != nil { if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err) fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
// Add to ISRC index for fast duplicate checking } else {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
// Return 0 to indicate unknown quality }
return AmazonDownloadResult{
FilePath: outputPath, // Read metadata from file AFTER embedding to get accurate values
BitDepth: 0, // This ensures we return what's actually in the file
SampleRate: 0, finalMeta, metaReadErr := ReadMetadata(outputPath)
}, nil if metaReadErr == nil && finalMeta != nil {
fmt.Printf("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
// Use date from file if available
req.ReleaseDate = finalMeta.Date
}
} }
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
// Add to ISRC index for fast duplicate checking // Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
bitDepth := 0
sampleRate := 0
if err == nil {
bitDepth = quality.BitDepth
sampleRate = quality.SampleRate
}
return AmazonDownloadResult{ return AmazonDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: quality.BitDepth, BitDepth: bitDepth,
SampleRate: quality.SampleRate, SampleRate: sampleRate,
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: actualTrackNum,
DiscNumber: req.DiscNumber, DiscNumber: actualDiscNum,
ISRC: req.ISRC,
}, nil }, nil
} }
+168 -94
View File
@@ -17,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
result := map[string]string{ result := map[string]string{
"type": parsed.Type, "type": parsed.Type,
"id": parsed.ID, "id": parsed.ID,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
func GetSpotifyMetadata(spotifyURL string) (string, error) { func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(data) jsonBytes, err := json.Marshal(data)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -62,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
func SearchSpotify(query string, limit int) (string, error) { func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
results, err := client.SearchTracks(ctx, query, limit) results, err := client.SearchTracks(ctx, query, limit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -105,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -133,7 +133,7 @@ type DownloadRequest struct {
DiscNumber int `json:"disc_number"` DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"` TotalTracks int `json:"total_tracks"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ItemID string `json:"item_id"` // Unique ID for progress tracking ItemID string `json:"item_id"` // Unique ID for progress tracking
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
} }
@@ -155,6 +155,7 @@ type DownloadResponse struct {
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
TrackNumber int `json:"track_number,omitempty"` TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"`
ISRC string `json:"isrc,omitempty"`
} }
// DownloadResult is a generic result type for all downloaders // DownloadResult is a generic result type for all downloaders
@@ -169,6 +170,7 @@ type DownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// DownloadTrack downloads a track from the specified service // DownloadTrack downloads a track from the specified service
@@ -179,17 +181,17 @@ func DownloadTrack(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues // Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir) req.OutputDir = strings.TrimSpace(req.OutputDir)
var result DownloadResult var result DownloadResult
var err error var err error
switch req.Service { switch req.Service {
case "tidal": case "tidal":
tidalResult, tidalErr := downloadFromTidal(req) tidalResult, tidalErr := downloadFromTidal(req)
@@ -204,6 +206,7 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: tidalResult.ReleaseDate, ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
} }
} }
err = tidalErr err = tidalErr
@@ -220,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: qobuzResult.ReleaseDate, ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
} }
} }
err = qobuzErr err = qobuzErr
@@ -236,17 +240,18 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
} }
} }
err = amazonErr err = amazonErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
if err != nil { if err != nil {
return errorResponse(err.Error()) return errorResponse(err.Error())
} }
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:] actualPath := result.FilePath[7:]
@@ -264,11 +269,18 @@ func DownloadTrack(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: req.Service, Service: req.Service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API) // Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath) quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil { if qErr == nil {
@@ -278,7 +290,7 @@ func DownloadTrack(requestJSON string) (string, error) {
} else { } else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Download complete", Message: "Download complete",
@@ -292,8 +304,9 @@ func DownloadTrack(requestJSON string) (string, error) {
ReleaseDate: result.ReleaseDate, ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -305,23 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error()) return errorResponse("Invalid request: " + err.Error())
} }
// Trim whitespace from string fields to prevent filename/path issues // Trim whitespace from string fields to prevent filename/path issues
req.TrackName = strings.TrimSpace(req.TrackName) req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName) req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
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{"qobuz", "tidal", "amazon"} allServices := []string{"tidal", "qobuz", "amazon"}
preferredService := req.Service preferredService := req.Service
if preferredService == "" { if preferredService == "" {
preferredService = "tidal" preferredService = "tidal"
} }
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) 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 {
@@ -329,18 +342,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
services = append(services, s) services = append(services, s)
} }
} }
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services) 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) fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service req.Service = service
var result DownloadResult var result DownloadResult
var err error var err error
switch service { switch service {
case "tidal": case "tidal":
tidalResult, tidalErr := downloadFromTidal(req) tidalResult, tidalErr := downloadFromTidal(req)
@@ -355,6 +368,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ReleaseDate: tidalResult.ReleaseDate, ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber, TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber, DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr) fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
@@ -364,9 +378,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
qobuzResult, qobuzErr := downloadFromQobuz(req) qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil { if qobuzErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: qobuzResult.FilePath, FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth, BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate, SampleRate: qobuzResult.SampleRate,
Title: qobuzResult.Title,
Artist: qobuzResult.Artist,
Album: qobuzResult.Album,
ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
@@ -385,13 +406,14 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
} }
} else { } else {
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr) fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
} }
err = amazonErr err = amazonErr
} }
if err == nil { if err == nil {
// Check if file already exists // Check if file already exists
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
@@ -410,11 +432,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: service, Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Read actual quality from downloaded file (more accurate than API) // Read actual quality from downloaded file (more accurate than API)
quality, qErr := GetAudioQuality(result.FilePath) quality, qErr := GetAudioQuality(result.FilePath)
if qErr == nil { if qErr == nil {
@@ -424,7 +453,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
} else { } else {
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: true, Success: true,
Message: "Downloaded from " + service, Message: "Downloaded from " + service,
@@ -432,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth, ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate, ActualSampleRate: result.SampleRate,
Service: service, Service: service,
Title: result.Title,
Artist: result.Artist,
Album: result.Album,
ReleaseDate: result.ReleaseDate,
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
} }
jsonBytes, _ := json.Marshal(resp) jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil return string(jsonBytes), nil
} }
lastErr = err lastErr = err
} }
return errorResponse("All services failed. Last error: " + lastErr.Error()) return errorResponse("All services failed. Last error: " + lastErr.Error())
} }
@@ -477,6 +513,44 @@ func CleanupConnections() {
CloseIdleConnections() CloseIdleConnections()
} }
// ReadFileMetadata reads metadata directly from a FLAC file
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
// This is useful for displaying accurate metadata in the UI without relying on cached data
func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
// Also get audio quality info
quality, qualityErr := GetAudioQuality(filePath)
result := map[string]interface{}{
"title": metadata.Title,
"artist": metadata.Artist,
"album": metadata.Album,
"album_artist": metadata.AlbumArtist,
"date": metadata.Date,
"track_number": metadata.TrackNumber,
"disc_number": metadata.DiscNumber,
"isrc": metadata.ISRC,
"lyrics": metadata.Lyrics,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// SetDownloadDirectory sets the default download directory // SetDownloadDirectory sets the default download directory
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
@@ -485,17 +559,17 @@ func SetDownloadDirectory(path string) error {
// CheckDuplicate checks if a file with the given ISRC exists // CheckDuplicate checks if a file with the given ISRC exists
func CheckDuplicate(outputDir, isrc string) (string, error) { func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc) existingFile, exists := CheckISRCExists(outputDir, isrc)
result := map[string]interface{}{ result := map[string]interface{}{
"exists": exists, "exists": exists,
"filepath": existingFile, "filepath": existingFile,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -525,7 +599,7 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return "", err return "", err
} }
filename := buildFilenameFromTemplate(template, metadata) filename := buildFilenameFromTemplate(template, metadata)
return filename, nil return filename, nil
} }
@@ -655,18 +729,18 @@ func ClearTrackIDCache() {
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(results) jsonBytes, err := json.Marshal(results)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -676,11 +750,11 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error)
func GetDeezerMetadata(resourceType, resourceID string) (string, error) { func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
var data interface{} var data interface{}
var err error var err error
switch resourceType { switch resourceType {
case "track": case "track":
data, err = client.GetTrack(ctx, resourceID) data, err = client.GetTrack(ctx, resourceID)
@@ -693,16 +767,16 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
default: default:
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
} }
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(data) jsonBytes, err := json.Marshal(data)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -712,17 +786,17 @@ func ParseDeezerURLExport(url string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
result := map[string]string{ result := map[string]string{
"type": resourceType, "type": resourceType,
"id": resourceID, "id": resourceID,
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -730,18 +804,18 @@ func ParseDeezerURLExport(url string) (string, error) {
func SearchDeezerByISRC(isrc string) (string, error) { func SearchDeezerByISRC(isrc string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
client := GetDeezerClient() client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc) track, err := client.SearchByISRC(ctx, isrc)
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(track) jsonBytes, err := json.Marshal(track)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -751,52 +825,52 @@ func SearchDeezerByISRC(isrc string) (string, error) {
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
// For tracks, we can use SongLink to get Deezer ID // For tracks, we can use SongLink to get Deezer ID
if resourceType == "track" { if resourceType == "track" {
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
if err != nil { if err != nil {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err) return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
} }
// Fetch metadata from Deezer // Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID) trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
} }
jsonBytes, err := json.Marshal(trackResp) jsonBytes, err := json.Marshal(trackResp)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// For albums, SongLink also provides mapping // For albums, SongLink also provides mapping
if resourceType == "album" { if resourceType == "album" {
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
if err != nil { if err != nil {
return "", fmt.Errorf("could not find Deezer album: %w", err) return "", fmt.Errorf("could not find Deezer album: %w", err)
} }
// Fetch album metadata from Deezer // Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID) albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
} }
jsonBytes, err := json.Marshal(albumResp) jsonBytes, err := json.Marshal(albumResp)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// For artists/playlists, SongLink doesn't provide direct mapping // 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) return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
} }
@@ -805,7 +879,7 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// Try Spotify first // Try Spotify first
client := NewSpotifyMetadataClient() client := NewSpotifyMetadataClient()
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -816,32 +890,32 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// Check if it's a rate limit error // Check if it's a rate limit error
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error // Not a rate limit error, return original error
return "", err return "", err
} }
// Rate limited - try Deezer fallback for tracks and albums // Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL) parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil { if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) 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) fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" { if parsed.Type == "track" || parsed.Type == "album" {
// Convert to Deezer // Convert to Deezer
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
} }
// Artist and playlist not supported for fallback // Artist and playlist not supported for fallback
if parsed.Type == "artist" { 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. Artist pages require Spotify API - please try again later")
} }
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
} }
@@ -855,12 +929,12 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -874,12 +948,12 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin
if err != nil { if err != nil {
return "", err return "", err
} }
jsonBytes, err := json.Marshal(availability) jsonBytes, err := json.Marshal(availability)
if err != nil { if err != nil {
return "", err return "", err
} }
return string(jsonBytes), nil return string(jsonBytes), nil
} }
@@ -905,24 +979,24 @@ func errorResponse(msg string) (string, error) {
// Determine error type based on message // Determine error type based on message
errorType := "unknown" errorType := "unknown"
lowerMsg := strings.ToLower(msg) lowerMsg := strings.ToLower(msg)
if strings.Contains(lowerMsg, "not found") || if strings.Contains(lowerMsg, "not found") ||
strings.Contains(lowerMsg, "not available") || strings.Contains(lowerMsg, "not available") ||
strings.Contains(lowerMsg, "no results") || strings.Contains(lowerMsg, "no results") ||
strings.Contains(lowerMsg, "track not found") || strings.Contains(lowerMsg, "track not found") ||
strings.Contains(lowerMsg, "all services failed") { strings.Contains(lowerMsg, "all services failed") {
errorType = "not_found" errorType = "not_found"
} else if strings.Contains(lowerMsg, "rate limit") || } else if strings.Contains(lowerMsg, "rate limit") ||
strings.Contains(lowerMsg, "429") || strings.Contains(lowerMsg, "429") ||
strings.Contains(lowerMsg, "too many requests") { strings.Contains(lowerMsg, "too many requests") {
errorType = "rate_limit" errorType = "rate_limit"
} else if strings.Contains(lowerMsg, "network") || } else if strings.Contains(lowerMsg, "network") ||
strings.Contains(lowerMsg, "connection") || strings.Contains(lowerMsg, "connection") ||
strings.Contains(lowerMsg, "timeout") || strings.Contains(lowerMsg, "timeout") ||
strings.Contains(lowerMsg, "dial") { strings.Contains(lowerMsg, "dial") {
errorType = "network" errorType = "network"
} }
resp := DownloadResponse{ resp := DownloadResponse{
Success: false, Success: false,
Error: msg, Error: msg,
+26 -2
View File
@@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) {
if trackNum != "" { if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
} }
// Also try lowercase variant (some encoders use lowercase)
if metadata.TrackNumber == 0 {
trackNum = getComment(cmt, "TRACK")
if trackNum != "" {
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
}
}
discNum := getComment(cmt, "DISCNUMBER") discNum := getComment(cmt, "DISCNUMBER")
if discNum != "" { if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
} }
// Also try DISC variant
if metadata.DiscNumber == 0 {
discNum = getComment(cmt, "DISC")
if discNum != "" {
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
}
}
// Try DATE variants
if metadata.Date == "" {
metadata.Date = getComment(cmt, "YEAR")
}
break break
} }
@@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
} }
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
keyUpper := strings.ToUpper(key) + "="
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" { if len(comment) > len(key) {
return comment[len(key)+1:] // Case-insensitive comparison for Vorbis comments
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
}
} }
} }
return "" return ""
+38 -23
View File
@@ -52,37 +52,37 @@ type QobuzTrack struct {
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match // Exact match
if normExpected == normFound { if normExpected == normFound {
return true return true
} }
// Check if one contains the other // Check if one contains the other
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
return true return true
} }
// Check first artist (before comma or feat) // Check first artist (before comma or feat)
expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst := strings.Split(normExpected, ",")[0]
expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0]
expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0]
expectedFirst = strings.TrimSpace(expectedFirst) expectedFirst = strings.TrimSpace(expectedFirst)
foundFirst := strings.Split(normFound, ",")[0] foundFirst := strings.Split(normFound, ",")[0]
foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " feat")[0]
foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.Split(foundFirst, " ft.")[0]
foundFirst = strings.TrimSpace(foundFirst) foundFirst = strings.TrimSpace(foundFirst)
if expectedFirst == foundFirst { if expectedFirst == foundFirst {
return true return true
} }
// Check if first artist is contained in the other // Check if first artist is contained in the other
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
return true return true
} }
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration // assume they're the same artist with different transliteration
expectedASCII := qobuzIsASCIIString(expectedArtist) expectedASCII := qobuzIsASCIIString(expectedArtist)
@@ -91,7 +91,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true return true
} }
return false return false
} }
@@ -132,8 +132,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
// Same APIs as PC version (referensi/backend/qobuz.go) // Same APIs as PC version (referensi/backend/qobuz.go)
// Primary: dab.yeet.su, Fallback: dabmusic.xyz // Primary: dab.yeet.su, Fallback: dabmusic.xyz
encodedAPIs := []string{ encodedAPIs := []string{
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
} }
var apis []string var apis []string
@@ -194,6 +194,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
// expectedDurationSec is the expected duration in seconds (0 to skip verification) // expectedDurationSec is the expected duration in seconds (0 to skip verification)
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
fmt.Printf("[Qobuz] Searching by ISRC: %s\n", isrc)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
@@ -221,6 +223,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return nil, err return nil, err
} }
fmt.Printf("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
// Find ISRC matches // Find ISRC matches
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range result.Tracks.Items {
@@ -229,6 +233,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
} }
} }
fmt.Printf("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration if provided // Verify duration if provided
if expectedDurationSec > 0 { if expectedDurationSec > 0 {
@@ -238,25 +244,25 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
// Allow 30 seconds tolerance // Allow 10 seconds tolerance
if durationDiff <= 30 { if durationDiff <= 10 {
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
} }
if len(durationVerifiedMatches) > 0 { if len(durationVerifiedMatches) > 0 {
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
// ISRC matches but duration doesn't // ISRC matches but duration doesn't
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
isrc, expectedDurationSec, isrcMatches[0].Duration) isrc, expectedDurationSec, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
expectedDurationSec, isrcMatches[0].Duration) expectedDurationSec, isrcMatches[0].Duration)
} }
// No duration to verify, return first match // No duration to verify, return first match
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
@@ -392,7 +398,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
if durationDiff < 0 { if durationDiff < 0 {
durationDiff = -durationDiff durationDiff = -durationDiff
} }
if durationDiff <= 30 { if durationDiff <= 10 {
durationMatches = append(durationMatches, track) durationMatches = append(durationMatches, track)
} }
} }
@@ -592,6 +598,7 @@ type QobuzDownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// downloadFromQobuz downloads a track using the request parameters // downloadFromQobuz downloads a track using the request parameters
@@ -624,10 +631,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
// Strategy 1: Search by ISRC with duration verification // Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
fmt.Printf("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist // Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
track = nil track = nil
} }
@@ -638,7 +646,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
// Verify artist // Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name) req.ArtistName, track.Performer.Name)
track = nil track = nil
} }
@@ -731,11 +739,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
} }
// Embed metadata using parallel-fetched cover data // Embed metadata using parallel-fetched cover data
// Use metadata from the actual Qobuz track found (more accurate than request) // Use metadata from the actual Qobuz track found (more accurate than request) but prefer
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
albumName := track.Album.Title
if req.AlbumName != "" {
albumName = req.AlbumName
}
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: track.Performer.Name,
Album: track.Album.Title, Album: albumName,
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
Date: track.Album.ReleaseDate, Date: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
@@ -780,5 +794,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
DiscNumber: req.DiscNumber, // Qobuz track struct limitations DiscNumber: req.DiscNumber, // Qobuz track struct limitations
ISRC: track.ISRC,
}, nil }, nil
} }
+95 -57
View File
@@ -133,11 +133,11 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
// Priority 2: qqdl.site APIs (often return PREVIEW only) // Priority 2: qqdl.site APIs (often return PREVIEW only)
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
} }
var apis []string var apis []string
@@ -297,7 +297,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
return &trackInfo, nil return &trackInfo, nil
} }
// SearchTrackByISRC searches for a track by ISRC // SearchTrackByISRC searches for a track by ISRC
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
token, err := t.GetAccessToken() token, err := t.GetAccessToken()
@@ -349,7 +348,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
// normalizeTitle normalizes a track title for comparison (kept for potential future use) // normalizeTitle normalizes a track title for comparison (kept for potential future use)
func normalizeTitle(title string) string { func normalizeTitle(title string) string {
normalized := strings.ToLower(strings.TrimSpace(title)) normalized := strings.ToLower(strings.TrimSpace(title))
// Remove common suffixes in parentheses or brackets // Remove common suffixes in parentheses or brackets
suffixPatterns := []string{ suffixPatterns := []string{
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
@@ -359,12 +358,12 @@ func normalizeTitle(title string) string {
for _, suffix := range suffixPatterns { for _, suffix := range suffixPatterns {
normalized = strings.TrimSuffix(normalized, suffix) normalized = strings.TrimSuffix(normalized, suffix)
} }
// Remove multiple spaces // Remove multiple spaces
for strings.Contains(normalized, " ") { for strings.Contains(normalized, " ") {
normalized = strings.ReplaceAll(normalized, " ", " ") normalized = strings.ReplaceAll(normalized, " ", " ")
} }
return normalized return normalized
} }
@@ -478,6 +477,33 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if len(result.Items) > 0 { if len(result.Items) > 0 {
fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
if spotifyISRC != "" {
for i := range result.Items {
if result.Items[i].ISRC == spotifyISRC {
track := &result.Items[i]
// Verify duration if provided
if expectedDuration > 0 {
durationDiff := track.Duration - expectedDuration
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff <= 3 {
fmt.Printf("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
return track, nil
}
// Duration mismatch, continue searching
fmt.Printf("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
expectedDuration, track.Duration)
} else {
fmt.Printf("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
return track, nil
}
}
}
}
allTracks = append(allTracks, result.Items...) allTracks = append(allTracks, result.Items...)
} }
} }
@@ -496,7 +522,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
isrcMatches = append(isrcMatches, track) isrcMatches = append(isrcMatches, track)
} }
} }
if len(isrcMatches) > 0 { if len(isrcMatches) > 0 {
// Verify duration first (most important check) // Verify duration first (most important check)
if expectedDuration > 0 { if expectedDuration > 0 {
@@ -511,26 +537,26 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
durationVerifiedMatches = append(durationVerifiedMatches, track) durationVerifiedMatches = append(durationVerifiedMatches, track)
} }
} }
if len(durationVerifiedMatches) > 0 { if len(durationVerifiedMatches) > 0 {
// Return first duration-verified match // Return first duration-verified match
fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
return durationVerifiedMatches[0], nil return durationVerifiedMatches[0], nil
} }
// ISRC matches but duration doesn't - this is likely wrong version // ISRC matches but duration doesn't - this is likely wrong version
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
spotifyISRC, expectedDuration, isrcMatches[0].Duration) spotifyISRC, expectedDuration, isrcMatches[0].Duration)
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
expectedDuration, isrcMatches[0].Duration) expectedDuration, isrcMatches[0].Duration)
} }
// No duration to verify, just return first ISRC match // No duration to verify, just return first ISRC match
fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
return isrcMatches[0], nil return isrcMatches[0], nil
} }
// If ISRC was provided but no match found, return error // If ISRC was provided but no match found, return error
fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC) fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
@@ -605,7 +631,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
} }
// TidalDownloadInfo contains download URL and quality info // TidalDownloadInfo contains download URL and quality info
type TidalDownloadInfo struct { type TidalDownloadInfo struct {
URL string URL string
@@ -648,7 +673,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
continue continue
} }
// Log response preview // Log response preview
bodyPreview := string(body) bodyPreview := string(body)
if len(bodyPreview) > 300 { if len(bodyPreview) > 300 {
@@ -659,16 +684,16 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
// Try v2 format first (object with manifest) // Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2 var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n", fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation) apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks // IMPORTANT: Reject PREVIEW responses - we need FULL tracks
if v2Response.Data.AssetPresentation == "PREVIEW" { if v2Response.Data.AssetPresentation == "PREVIEW" {
fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL) fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL")) errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
continue continue
} }
fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL) fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL)
info := TidalDownloadInfo{ info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest, URL: "MANIFEST:" + v2Response.Data.Manifest,
@@ -725,7 +750,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} }
manifestStr := string(manifestBytes) manifestStr := string(manifestBytes)
// Debug: log first 500 chars of manifest for debugging // Debug: log first 500 chars of manifest for debugging
manifestPreview := manifestStr manifestPreview := manifestStr
if len(manifestPreview) > 500 { if len(manifestPreview) > 500 {
@@ -817,7 +842,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil return "", initURL, mediaURLs, nil
} }
// DownloadFile downloads a file from URL with progress tracking // DownloadFile downloads a file from URL with progress tracking
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
// Handle manifest-based download (DASH/BTS) // Handle manifest-based download (DASH/BTS)
@@ -909,7 +933,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
fmt.Printf("[Tidal] Manifest parse error: %v\n", err) fmt.Printf("[Tidal] Manifest parse error: %v\n", err)
return fmt.Errorf("failed to parse manifest: %w", err) return fmt.Errorf("failed to parse manifest: %w", err)
} }
fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
directURL != "", initURL != "", len(mediaURLs)) directURL != "", initURL != "", len(mediaURLs))
client := &http.Client{ client := &http.Client{
@@ -984,10 +1008,10 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
// On Android, we can't use ffmpeg, so we save as M4A directly // On Android, we can't use ffmpeg, so we save as M4A directly
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal) // Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
// We just update progress here based on segment count // We just update progress here based on segment count
out, err := os.Create(m4aPath) out, err := os.Create(m4aPath)
if err != nil { if err != nil {
fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err) fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err)
@@ -1025,13 +1049,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
if i%10 == 0 || i == totalSegments-1 { if i%10 == 0 || i == totalSegments-1 {
fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments) fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
} }
// Update progress based on segment count // Update progress based on segment count
if itemID != "" { if itemID != "" {
progress := float64(i+1) / float64(totalSegments) progress := float64(i+1) / float64(totalSegments)
SetItemProgress(itemID, progress, 0, 0) SetItemProgress(itemID, progress, 0, 0)
} }
resp, err := client.Get(mediaURL) resp, err := client.Get(mediaURL)
if err != nil { if err != nil {
out.Close() out.Close()
@@ -1077,43 +1101,44 @@ type TidalDownloadResult struct {
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string
} }
// artistsMatch checks if the artist names are similar enough // artistsMatch checks if the artist names are similar enough
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
// Exact match // Exact match
if normSpotify == normTidal { if normSpotify == normTidal {
return true return true
} }
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone") // Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
return true return true
} }
// Check first artist (before comma or feat) // Check first artist (before comma or feat)
spotifyFirst := strings.Split(normSpotify, ",")[0] spotifyFirst := strings.Split(normSpotify, ",")[0]
spotifyFirst = strings.Split(spotifyFirst, " feat")[0] spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0] spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
spotifyFirst = strings.TrimSpace(spotifyFirst) spotifyFirst = strings.TrimSpace(spotifyFirst)
tidalFirst := strings.Split(normTidal, ",")[0] tidalFirst := strings.Split(normTidal, ",")[0]
tidalFirst = strings.Split(tidalFirst, " feat")[0] tidalFirst = strings.Split(tidalFirst, " feat")[0]
tidalFirst = strings.Split(tidalFirst, " ft.")[0] tidalFirst = strings.Split(tidalFirst, " ft.")[0]
tidalFirst = strings.TrimSpace(tidalFirst) tidalFirst = strings.TrimSpace(tidalFirst)
if spotifyFirst == tidalFirst { if spotifyFirst == tidalFirst {
return true return true
} }
// Check if first artist is contained in the other // Check if first artist is contained in the other
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) { if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
return true return true
} }
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
// assume they're the same artist with different transliteration // assume they're the same artist with different transliteration
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki" // This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
@@ -1123,7 +1148,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true return true
} }
return false return false
} }
@@ -1164,13 +1189,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
} }
// OPTIMIZED: Try ISRC search first (faster than SongLink API) // OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
// Strategy 1: Search by ISRC with duration verification (FASTEST) // Strategy 1: Search by metadata, match by ISRC (most accurate)
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC) fmt.Printf("[Tidal] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
// Verify artist for ISRC match
if track != nil { if track != nil {
// Verify artist
tidalArtist := track.Artist.Name tidalArtist := track.Artist.Name
if len(track.Artists) > 0 { if len(track.Artists) > 0 {
var artistNames []string var artistNames []string
@@ -1180,7 +1205,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ") tidalArtist = strings.Join(artistNames, ", ")
} }
if !artistsMatch(req.ArtistName, tidalArtist) { if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist) req.ArtistName, tidalArtist)
track = nil track = nil
} }
@@ -1190,7 +1215,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
if track == nil && req.SpotifyID != "" { if track == nil && req.SpotifyID != "" {
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n") fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID) var tidalURL string
var slErr error
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
fmt.Printf("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
songlink := NewSongLinkClient()
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
} else {
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
}
if slErr == nil && tidalURL != "" { if slErr == nil && tidalURL != "" {
// Extract track ID and get track info // Extract track ID and get track info
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
@@ -1206,14 +1243,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
tidalArtist = strings.Join(artistNames, ", ") tidalArtist = strings.Join(artistNames, ", ")
} }
// Verify artist matches // Verify artist matches
if !artistsMatch(req.ArtistName, tidalArtist) { if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist) req.ArtistName, tidalArtist)
track = nil track = nil
} }
// Verify duration if we have expected duration // Verify duration if we have expected duration
if track != nil && expectedDurationSec > 0 { if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec durationDiff := track.Duration - expectedDurationSec
@@ -1222,7 +1259,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
// Allow 3 seconds tolerance (same as PC version) // Allow 3 seconds tolerance (same as PC version)
if durationDiff > 3 { if durationDiff > 3 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration) expectedDurationSec, track.Duration)
track = nil // Reject this match track = nil // Reject this match
} }
@@ -1247,7 +1284,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
tidalArtist = strings.Join(artistNames, ", ") tidalArtist = strings.Join(artistNames, ", ")
} }
if !artistsMatch(req.ArtistName, tidalArtist) { if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist) req.ArtistName, tidalArtist)
track = nil track = nil
} }
@@ -1298,7 +1335,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
} }
// Clean up any leftover .tmp files from previous failed downloads // Clean up any leftover .tmp files from previous failed downloads
tmpPath := outputPath + ".m4a.tmp" tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil { if _, err := os.Stat(tmpPath); err == nil {
@@ -1345,7 +1382,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
return "Direct URL" return "Direct URL"
}()) }())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
fmt.Printf("[Tidal] Download failed with error: %v\n", err) fmt.Printf("[Tidal] Download failed with error: %v\n", err)
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
@@ -1381,10 +1418,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate, Date: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: track.TrackNumber, // Use actual track number from Tidal
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber, DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
ISRC: req.ISRC, ISRC: track.ISRC, // Use actual ISRC from Tidal
} }
// Use cover data from parallel fetch // Use cover data from parallel fetch
@@ -1414,18 +1451,18 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} else if strings.HasSuffix(actualOutputPath, ".m4a") { } else if strings.HasSuffix(actualOutputPath, ".m4a") {
// Embed metadata to M4A file // Embed metadata to M4A file
// fmt.Printf("[Tidal] Embedding metadata to M4A file...\n") // fmt.Printf("[Tidal] Embedding metadata to M4A file...\n")
// Add lyrics to metadata if available // Add lyrics to metadata if available
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { // if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
// metadata.Lyrics = parallelResult.LyricsLRC // metadata.Lyrics = parallelResult.LyricsLRC
// } // }
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion // SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
// M4A files from DASH are often fragmented and editing metadata might corrupt the container // M4A files from DASH are often fragmented and editing metadata might corrupt the container
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter. // structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil { // if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
// fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err) // fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
// } else { // } else {
@@ -1446,5 +1483,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ReleaseDate: track.Album.ReleaseDate, ReleaseDate: track.Album.ReleaseDate,
TrackNumber: track.TrackNumber, TrackNumber: track.TrackNumber,
DiscNumber: track.VolumeNumber, DiscNumber: track.VolumeNumber,
ISRC: track.ISRC,
}, nil }, nil
} }
+7
View File
@@ -181,6 +181,13 @@ import Gobackend // Import Go framework
GobackendCleanupConnections() GobackendCleanupConnections()
return nil return nil
case "readFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let response = GobackendReadFileMetadata(filePath, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+2 -2
View 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.7'; static const String version = '2.2.0';
static const String buildNumber = '45'; static const String buildNumber = '46';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+1 -1
View File
@@ -25,7 +25,7 @@ class AppSettings {
final String metadataSource; // spotify, deezer - source for search and metadata final String metadataSource; // spotify, deezer - source for search and metadata
const AppSettings({ const AppSettings({
this.defaultService = 'qobuz', this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS', this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}', this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '', this.downloadDirectory = '',
+72 -33
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -35,6 +36,9 @@ class DownloadHistoryItem {
final int? duration; final int? duration;
final String? releaseDate; final String? releaseDate;
final String? quality; final String? quality;
// Audio quality info (from file after download)
final int? bitDepth;
final int? sampleRate;
const DownloadHistoryItem({ const DownloadHistoryItem({
required this.id, required this.id,
@@ -53,6 +57,8 @@ class DownloadHistoryItem {
this.duration, this.duration,
this.releaseDate, this.releaseDate,
this.quality, this.quality,
this.bitDepth,
this.sampleRate,
}); });
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -72,6 +78,8 @@ class DownloadHistoryItem {
'duration': duration, 'duration': duration,
'releaseDate': releaseDate, 'releaseDate': releaseDate,
'quality': quality, 'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
}; };
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem( factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
@@ -91,6 +99,8 @@ class DownloadHistoryItem {
duration: json['duration'] as int?, duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?, releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?, quality: json['quality'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
); );
} }
@@ -763,7 +773,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) { if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
try { try {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_$uniqueId.jpg'; coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
// Download cover using HTTP // Download cover using HTTP
@@ -821,6 +831,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (track.isrc != null) { if (track.isrc != null) {
metadata['ISRC'] = track.isrc!; metadata['ISRC'] = track.isrc!;
} }
_log.d('Metadata map content: $metadata');
// Fetch Lyrics (Critical for M4A->FLAC conversion parity) // Fetch Lyrics (Critical for M4A->FLAC conversion parity)
// Since we are in the Flutter context, we can call the bridge to get lyrics // Since we are in the Flutter context, we can call the bridge to get lyrics
@@ -1094,40 +1106,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// If track number is missing/0 (common from Search results), fetch full metadata // If track number is missing/0 (common from Search results), fetch full metadata
// This ensures the downloaded file has correct tags (Track, Disc, Year) // This ensures the downloaded file has correct tags (Track, Disc, Year)
Track trackToDownload = item.track; Track trackToDownload = item.track;
if (trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0) { // Enrich metadata if ISRC or track number is missing (common from Search results)
// ISRC is critical for accurate track matching on streaming services
final needsEnrichment = trackToDownload.id.startsWith('deezer:') &&
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0);
if (needsEnrichment) {
try { try {
if (trackToDownload.id.startsWith('deezer:')) { _log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}'); _log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}');
final rawId = trackToDownload.id.split(':')[1]; final rawId = trackToDownload.id.split(':')[1];
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId); _log.d('Fetching full metadata for Deezer ID: $rawId');
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
if (fullData.containsKey('track')) { _log.d('Got response keys: ${fullData.keys.toList()}');
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
// Merge with existing (keep override quality/service if any, but update metadata) if (fullData.containsKey('track')) {
// Parse Go backend response (snake_case) to Track
final trackData = fullData['track'];
_log.d('Track data type: ${trackData.runtimeType}');
if (trackData is Map<String, dynamic>) {
final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}');
trackToDownload = Track( trackToDownload = Track(
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id, id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: fullTrack.name, name: (data['name'] as String?) ?? trackToDownload.name,
artistName: fullTrack.artistName, artistName: (data['artists'] as String?) ?? trackToDownload.artistName,
albumName: fullTrack.albumName, albumName: (data['album_name'] as String?) ?? trackToDownload.albumName,
albumArtist: fullTrack.albumArtist, albumArtist: data['album_artist'] as String?,
coverUrl: fullTrack.coverUrl, coverUrl: data['images'] as String?,
duration: fullTrack.duration, // duration_ms from Go is in milliseconds, Track.duration is in seconds
isrc: fullTrack.isrc ?? trackToDownload.isrc, duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000,
trackNumber: fullTrack.trackNumber, isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
discNumber: fullTrack.discNumber, trackNumber: data['track_number'] as int?,
releaseDate: fullTrack.releaseDate, discNumber: data['disc_number'] as int?,
deezerId: fullTrack.deezerId, releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
); );
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}'); _log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}');
} else {
// Update item in state with enriched track _log.w('Unexpected track data type: ${trackData.runtimeType}');
// This is important so the UI (and history) reflects the enriched data }
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var } else {
} _log.w('Response does not contain track key');
} }
} catch (e) { } catch (e, stack) {
_log.w('Failed to enrich metadata: $e'); _log.w('Failed to enrich metadata: $e');
_log.w('Stack trace: $stack');
} }
} }
@@ -1257,7 +1284,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?; final backendYear = result['release_date'] as String?;
final backendAlbum = result['album'] as String?; final backendAlbum = result['album'] as String?;
// Create updated track object _log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear');
// Create updated track object with safety check for 0/null
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber;
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber;
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber');
finalTrack = Track( finalTrack = Track(
id: trackToDownload.id, id: trackToDownload.id,
name: trackToDownload.name, name: trackToDownload.name,
@@ -1267,8 +1301,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
coverUrl: trackToDownload.coverUrl, coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration, duration: trackToDownload.duration,
isrc: trackToDownload.isrc, isrc: trackToDownload.isrc,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber, trackNumber: newTrackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber, discNumber: newDiscNumber,
releaseDate: backendYear ?? trackToDownload.releaseDate, releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId, deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
@@ -1337,6 +1371,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendYear = result['release_date'] as String?; final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?; final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?; final backendDiscNum = result['disc_number'] as int?;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
ref.read(downloadHistoryProvider.notifier).addToHistory( ref.read(downloadHistoryProvider.notifier).addToHistory(
DownloadHistoryItem( DownloadHistoryItem(
@@ -1350,13 +1387,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
service: result['service'] as String? ?? item.service, service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(), downloadedAt: DateTime.now(),
// Additional metadata // Additional metadata
isrc: item.track.isrc, isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc,
spotifyId: item.track.id, spotifyId: item.track.id,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber, trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber, discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
duration: item.track.duration, duration: item.track.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate, releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
quality: actualQuality, quality: actualQuality,
bitDepth: backendBitDepth,
sampleRate: backendSampleRate,
), ),
); );
+64 -59
View File
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final file = File(widget.item.filePath); final file = File(widget.item.filePath);
final exists = await file.exists(); final exists = await file.exists();
int? size; int? size;
if (exists) { if (exists) {
try { try {
size = await file.length(); size = await file.length();
} catch (_) {} } catch (_) {}
} }
if (mounted) { if (mounted) {
setState(() { setState(() {
_fileExists = exists; _fileExists = exists;
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
// Use data directly from history item (cached from download)
DownloadHistoryItem get item => widget.item; DownloadHistoryItem get item => widget.item;
String get trackName => item.trackName;
String get artistName => item.artistName;
String get albumName => item.albumName;
String? get albumArtist => item.albumArtist;
int? get trackNumber => item.trackNumber;
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
int? get bitDepth => item.bitDepth;
int? get sampleRate => item.sampleRate;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Track name // Track name (from file metadata)
Text( Text(
item.trackName, trackName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: colorScheme.onSurface, color: colorScheme.onSurface,
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// Artist name // Artist name (from file metadata)
Text( Text(
item.artistName, artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Album name // Album name (from file metadata)
Row( Row(
children: [ children: [
Icon( Icon(
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
item.albumName, albumName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -401,28 +414,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
// Build audio quality string from file metadata
String? audioQualityStr;
if (bitDepth != null && sampleRate != null) {
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
}
final items = <_MetadataItem>[ final items = <_MetadataItem>[
_MetadataItem('Track name', item.trackName), _MetadataItem('Track name', trackName),
_MetadataItem('Artist', item.artistName), _MetadataItem('Artist', artistName),
if (item.albumArtist != null && item.albumArtist != item.artistName) if (albumArtist != null && albumArtist != artistName)
_MetadataItem('Album artist', item.albumArtist!), _MetadataItem('Album artist', albumArtist!),
_MetadataItem('Album', item.albumName), _MetadataItem('Album', albumName),
if (item.trackNumber != null) if (trackNumber != null && trackNumber! > 0)
_MetadataItem('Track number', item.trackNumber.toString()), _MetadataItem('Track number', trackNumber.toString()),
if (item.discNumber != null && item.discNumber! > 1) if (discNumber != null && discNumber! > 1)
_MetadataItem('Disc number', item.discNumber.toString()), _MetadataItem('Disc number', discNumber.toString()),
if (item.duration != null) if (item.duration != null)
_MetadataItem('Duration', _formatDuration(item.duration!)), _MetadataItem('Duration', _formatDuration(item.duration!)),
if (item.quality != null && item.quality!.contains('bit')) if (audioQualityStr != null)
_MetadataItem('Audio quality', item.quality!), _MetadataItem('Audio quality', audioQualityStr),
if (item.releaseDate != null && item.releaseDate!.isNotEmpty) if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem('Release date', item.releaseDate!), _MetadataItem('Release date', releaseDate!),
if (item.isrc != null && item.isrc!.isNotEmpty) if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', item.isrc!), _MetadataItem('ISRC', isrc!),
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
_MetadataItem('Spotify ID', item.spotifyId!), _MetadataItem('Spotify ID', item.spotifyId!),
if (item.quality != null && item.quality!.isNotEmpty)
_MetadataItem('Quality', _formatQuality(item.quality!)),
_MetadataItem('Service', item.service.toUpperCase()), _MetadataItem('Service', item.service.toUpperCase()),
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)), _MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
]; ];
@@ -476,32 +494,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return '$minutes:${secs.toString().padLeft(2, '0')}'; return '$minutes:${secs.toString().padLeft(2, '0')}';
} }
String _formatQuality(String quality) {
switch (quality) {
case 'LOSSLESS':
return 'Lossless (16-bit)';
case 'HI_RES':
return 'Hi-Res (24-bit)';
case 'HI_RES_LOSSLESS':
return 'Hi-Res Lossless (24-bit)';
default:
return quality;
}
}
String _formatQualityShort(String quality) {
switch (quality) {
case 'LOSSLESS':
return '16-bit';
case 'HI_RES':
return '24-bit';
case 'HI_RES_LOSSLESS':
return 'Hi-Res';
default:
return quality;
}
}
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
final fileName = item.filePath.split(Platform.pathSeparator).last; final fileName = item.filePath.split(Platform.pathSeparator).last;
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
@@ -570,7 +562,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
if (item.quality != null) if (bitDepth != null && sampleRate != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -578,7 +570,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
_formatQualityShort(item.quality!), '$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
style: TextStyle( style: TextStyle(
color: colorScheme.onTertiaryContainer, color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -891,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
ListTile( ListTile(
leading: Icon(Icons.delete, color: colorScheme.error), leading: Icon(Icons.delete, color: colorScheme.error),
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)), title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_confirmDelete(context, ref, colorScheme); _confirmDelete(context, ref, colorScheme);
@@ -908,10 +900,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Remove from history?'), title: const Text('Remove from device?'),
content: const Text( content: const Text(
'This will remove the track from your download history. ' 'This will permanently delete the downloaded file and remove it from your history.',
'The downloaded file will not be deleted.',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -919,12 +910,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
// Delete the file first
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
debugPrint('Failed to delete file: $e');
}
// Remove from history
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history if (context.mounted) {
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Go back to history
}
}, },
child: Text('Remove', style: TextStyle(color: colorScheme.error)), child: Text('Delete', style: TextStyle(color: colorScheme.error)),
), ),
], ],
), ),
+14 -1
View File
@@ -111,7 +111,7 @@ class PlatformBridge {
int discNumber = 1, int discNumber = 1,
int totalTracks = 1, int totalTracks = 1,
String? releaseDate, String? releaseDate,
String preferredService = 'qobuz', String preferredService = 'tidal',
String? itemId, String? itemId,
int durationMs = 0, int durationMs = 0,
}) async { }) async {
@@ -248,6 +248,16 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
/// Read metadata directly from a FLAC file
/// Returns all embedded metadata (title, artist, album, track number, etc.)
/// This reads from the actual file, not from cached/database data
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Start foreground download service to keep downloads running in background /// Start foreground download service to keep downloads running in background
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
@@ -335,6 +345,9 @@ class PlatformBridge {
'resource_type': resourceType, 'resource_type': resourceType,
'resource_id': resourceId, 'resource_id': resourceId,
}); });
if (result == null) {
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
}
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
+1 -1
View File
@@ -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.7+45 version: 2.2.0+46
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
+1 -1
View File
@@ -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.7+45 version: 2.2.0+46
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0