diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dac9d6..85122c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.0.5] - 2026-01-05 + +### Added +- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100) + +### Fixed +- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance). + ## [2.0.4] - 2026-01-04 ### Fixed diff --git a/go_backend/exports.go b/go_backend/exports.go index e504b119..b8971001 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -132,7 +132,8 @@ type DownloadRequest struct { DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` 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) } // DownloadResponse represents the result of a download diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index db4c303b..f3ebc802 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -112,8 +112,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } +// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification +// expectedDurationSec is the expected duration in seconds (0 to skip verification) +func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") + searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + var result struct { + Tracks struct { + Items []QobuzTrack `json:"items"` + } `json:"tracks"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Find ISRC matches + var isrcMatches []*QobuzTrack + for i := range result.Tracks.Items { + if result.Tracks.Items[i].ISRC == isrc { + isrcMatches = append(isrcMatches, &result.Tracks.Items[i]) + } + } + + if len(isrcMatches) > 0 { + // Verify duration if provided + if expectedDurationSec > 0 { + var durationVerifiedMatches []*QobuzTrack + for _, track := range isrcMatches { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + // Allow 30 seconds tolerance + if durationDiff <= 30 { + durationVerifiedMatches = append(durationVerifiedMatches, track) + } + } + + if len(durationVerifiedMatches) > 0 { + fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", + durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) + return durationVerifiedMatches[0], nil + } + + // ISRC matches but duration doesn't + fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", + isrc, expectedDurationSec, isrcMatches[0].Duration) + return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", + expectedDurationSec, isrcMatches[0].Duration) + } + + // No duration to verify, return first match + fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) + return isrcMatches[0], nil + } + + if len(result.Tracks.Items) == 0 { + return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) + } + + return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) +} + +// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead +func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { + return q.SearchTrackByISRCWithDuration(isrc, 0) +} + // SearchTrackByMetadata searches for a track using artist name and track name func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { + return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) +} + +// SearchTrackByMetadataWithDuration searches for a track with duration verification +func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") // Try multiple search strategies @@ -129,6 +217,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* queries = append(queries, trackName) } + var allTracks []QobuzTrack + for _, query := range queries { searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID) @@ -159,19 +249,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* resp.Body.Close() if len(result.Tracks.Items) > 0 { - // Return first result with best quality - for i := range result.Tracks.Items { - track := &result.Tracks.Items[i] + allTracks = append(allTracks, result.Tracks.Items...) + } + } + + if len(allTracks) == 0 { + return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) + } + + // If duration verification is requested + if expectedDurationSec > 0 { + var durationMatches []*QobuzTrack + for i := range allTracks { + track := &allTracks[i] + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff <= 30 { + durationMatches = append(durationMatches, track) + } + } + + if len(durationMatches) > 0 { + // Return best quality among duration matches + for _, track := range durationMatches { if track.MaximumBitDepth >= 24 { return track, nil } } - // Return first result if no hi-res found - return &result.Tracks.Items[0], nil + return durationMatches[0], nil } + + // No duration match found + return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec) } - return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) + // No duration verification, return best quality + for i := range allTracks { + track := &allTracks[i] + if track.MaximumBitDepth >= 24 { + return track, nil + } + } + return &allTracks[0], nil } // getQobuzDownloadURLSequential requests download URL from APIs sequentially @@ -321,17 +442,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } + // Convert expected duration from ms to seconds + expectedDurationSec := req.DurationMS / 1000 + var track *QobuzTrack var err error - // Strategy 1: Search by ISRC + // Strategy 1: Search by ISRC with duration verification if req.ISRC != "" { - track, err = downloader.SearchTrackByISRC(req.ISRC) + track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) } - // Strategy 2: Search by metadata + // Strategy 2: Search by metadata with duration verification if track == nil { - track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) + track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) } if track == nil { @@ -342,6 +466,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) } + // Final duration verification + if expectedDurationSec > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff > 30 { + return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version", + expectedDurationSec, track.Duration, durationDiff) + } + fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n", + expectedDurationSec, track.Duration, durationDiff) + } + // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, diff --git a/go_backend/spotify.go b/go_backend/spotify.go index b1c919f8..10d47ed1 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -567,6 +567,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s } func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { + // First request to get playlist info and first batch of tracks var data struct { Name string `json:"name"` Images []image `json:"images"` @@ -577,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t Items []struct { Track *trackFull `json:"track"` } `json:"items"` - Total int `json:"total"` + Total int `json:"total"` + Next string `json:"next"` } `json:"tracks"` } @@ -591,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t info.Owner.Name = data.Name info.Owner.Images = firstImageURL(data.Images) - tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items)) + // Pre-allocate with expected capacity + tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) + + // Add first batch of tracks for _, item := range data.Tracks.Items { if item.Track == nil { continue @@ -615,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t }) } + // Fetch remaining tracks using pagination (up to 1000 tracks max) + nextURL := data.Tracks.Next + maxTracks := 1000 + + for nextURL != "" && len(tracks) < maxTracks { + var pageData struct { + Items []struct { + Track *trackFull `json:"track"` + } `json:"items"` + Next string `json:"next"` + } + + if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { + // Log error but return what we have so far + fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) + break + } + + for _, item := range pageData.Items { + if item.Track == nil { + continue + } + if len(tracks) >= maxTracks { + break + } + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.Track.ID, + Artists: joinArtists(item.Track.Artists), + Name: item.Track.Name, + AlbumName: item.Track.Album.Name, + AlbumArtist: joinArtists(item.Track.Album.Artists), + DurationMS: item.Track.DurationMS, + Images: firstImageURL(item.Track.Album.Images), + ReleaseDate: item.Track.Album.ReleaseDate, + TrackNumber: item.Track.TrackNumber, + TotalTracks: item.Track.Album.TotalTracks, + DiscNumber: item.Track.DiscNumber, + ExternalURL: item.Track.ExternalURL.Spotify, + ISRC: item.Track.ExternalID.ISRC, + AlbumID: item.Track.Album.ID, + AlbumURL: item.Track.Album.ExternalURL.Spotify, + }) + } + + nextURL = pageData.Next + } + + fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total) + return &PlaylistResponsePayload{ PlaylistInfo: info, TrackList: tracks, diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 142fc662..95e92103 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } +// normalizeTitle normalizes a track title for comparison (kept for potential future use) +func normalizeTitle(title string) string { + normalized := strings.ToLower(strings.TrimSpace(title)) + + // Remove common suffixes in parentheses or brackets + suffixPatterns := []string{ + " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", + " (bonus track)", " (single)", " (album version)", " (radio edit)", + " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", + } + for _, suffix := range suffixPatterns { + normalized = strings.TrimSuffix(normalized, suffix) + } + + // Remove multiple spaces + for strings.Contains(normalized, " ") { + normalized = strings.ReplaceAll(normalized, " ", " ") + } + + return normalized +} + // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { token, err := t.GetAccessToken() @@ -390,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return nil, fmt.Errorf("no tracks found for any search query") } - // Priority 1: Match by ISRC (exact match) + // Priority 1: Match by ISRC (exact match) WITH title verification if spotifyISRC != "" { + var isrcMatches []*TidalTrack for i := range allTracks { track := &allTracks[i] if track.ISRC == spotifyISRC { - return track, nil + isrcMatches = append(isrcMatches, track) } } + + if len(isrcMatches) > 0 { + // Verify duration first (most important check) + if expectedDuration > 0 { + var durationVerifiedMatches []*TidalTrack + for _, track := range isrcMatches { + durationDiff := track.Duration - expectedDuration + if durationDiff < 0 { + durationDiff = -durationDiff + } + // Allow 30 seconds tolerance for duration + if durationDiff <= 30 { + durationVerifiedMatches = append(durationVerifiedMatches, track) + } + } + + if len(durationVerifiedMatches) > 0 { + // Return first duration-verified match + fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", + durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration) + return durationVerifiedMatches[0], nil + } + + // 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", + spotifyISRC, expectedDuration, isrcMatches[0].Duration) + return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", + expectedDuration, isrcMatches[0].Duration) + } + + // No duration to verify, just return first ISRC match + fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) + return isrcMatches[0], nil + } + // If ISRC was provided but no match found, return error return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) } @@ -820,6 +878,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } + // Convert expected duration from ms to seconds + expectedDurationSec := req.DurationMS / 1000 + var track *TidalTrack var err error @@ -831,18 +892,31 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) if idErr == nil { track, err = downloader.GetTrackInfoByID(trackID) + // Verify duration if we have expected duration + if track != nil && expectedDurationSec > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + // Allow 30 seconds tolerance + if durationDiff > 30 { + fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", + expectedDurationSec, track.Duration) + track = nil // Reject this match + } + } } } } - // Strategy 2: Search by ISRC with multi-strategy fallback + // Strategy 2: Search by ISRC with duration verification if track == nil && req.ISRC != "" { - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0) + track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) } // Strategy 3: Search by metadata only (no ISRC requirement) if track == nil { - track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName) + track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) } if track == nil { @@ -853,6 +927,20 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg) } + // Final duration verification + if expectedDurationSec > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff > 30 { + return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version", + expectedDurationSec, track.Duration, durationDiff) + } + fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n", + expectedDurationSec, track.Duration, durationDiff) + } + // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 454bd5d4..a4d623ce 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.0.4'; - static const String buildNumber = '34'; + static const String version = '2.0.5'; + static const String buildNumber = '35'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 67cbb908..dc584f8d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1007,6 +1007,7 @@ class DownloadQueueNotifier extends Notifier { releaseDate: item.track.releaseDate, preferredService: item.service, itemId: item.id, // Pass item ID for progress tracking + durationMs: item.track.duration, // Duration in ms for verification ); } else { result = await PlatformBridge.downloadTrack( @@ -1025,6 +1026,7 @@ class DownloadQueueNotifier extends Notifier { discNumber: item.track.discNumber ?? 1, releaseDate: item.track.releaseDate, itemId: item.id, // Pass item ID for progress tracking + durationMs: item.track.duration, // Duration in ms for verification ); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 36f357fc..c1f6f2f8 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -65,6 +65,7 @@ class PlatformBridge { int totalTracks = 1, String? releaseDate, String? itemId, + int durationMs = 0, }) async { final request = jsonEncode({ 'isrc': isrc, @@ -85,6 +86,7 @@ class PlatformBridge { 'total_tracks': totalTracks, 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', + 'duration_ms': durationMs, }); final result = await _channel.invokeMethod('downloadTrack', request); @@ -111,6 +113,7 @@ class PlatformBridge { String? releaseDate, String preferredService = 'tidal', String? itemId, + int durationMs = 0, }) async { final request = jsonEncode({ 'isrc': isrc, @@ -131,6 +134,7 @@ class PlatformBridge { 'total_tracks': totalTracks, 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', + 'duration_ms': durationMs, }); final result = await _channel.invokeMethod('downloadWithFallback', request); diff --git a/pubspec.yaml b/pubspec.yaml index 89dd9e81..e9391ea2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.4+34 +version: 2.0.5+35 environment: sdk: ^3.10.0