diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index bef0e2ff..974ffcf6 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { } } if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) + if err := verifyDeezerTrack(req, deezerID); err != nil { + GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) + // Don't reject direct IDs from request payload — they're presumably correct. + } + return trackURL, nil } - // Try resolving Deezer ID from Spotify ID via SongLink + // Try SongLink spotifyID := strings.TrimSpace(req.SpotifyID) if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { songlink := NewSongLinkClient() availability, err := songlink.CheckTrackAvailability(spotifyID, "") if err == nil && availability.Deezer && availability.DeezerURL != "" { - return availability.DeezerURL, nil + resolvedID := extractDeezerIDFromURL(availability.DeezerURL) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) + // Fall through to ISRC search instead of using wrong track. + } else { + return availability.DeezerURL, nil + } + } else { + return availability.DeezerURL, nil + } } } - // Try resolving from ISRC + // Try ISRC isrc := strings.TrimSpace(req.ISRC) if isrc != "" { ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) defer cancel() track, err := GetDeezerClient().SearchByISRC(ctx, isrc) if err == nil && track != nil { - deezerID = songLinkExtractDeezerTrackID(track) - if deezerID != "" { - return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + resolvedID := songLinkExtractDeezerTrackID(track) + if resolvedID != "" { + if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { + GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) + return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) + } + return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil } } } @@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) { return "", fmt.Errorf("could not resolve Deezer track URL") } +func verifyDeezerTrack(req DownloadRequest, deezerID string) error { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) + if err != nil { + return nil // Can't verify — don't block the download. + } + resolved := resolvedTrackInfo{ + Title: trackResp.Track.Name, + ArtistName: trackResp.Track.Artists, + Duration: trackResp.Track.DurationMS / 1000, + } + if !trackMatchesRequest(req, resolved, "Deezer") { + return fmt.Errorf("expected '%s - %s', got '%s - %s'", + req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title) + return nil +} + type deezerMusicDLRequest struct { Platform string `json:"platform"` URL string `json:"url"` diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 582e4ff6..15f17b1c 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1911,6 +1911,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink") } + // Verify the resolved track matches the request. + actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10)) + if fetchErr != nil { + GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) + // Continue without verification — better than failing entirely. + } else { + providerArtist := actualTrack.Artist.Name + if providerArtist == "" && len(actualTrack.Artists) > 0 { + providerArtist = actualTrack.Artists[0].Name + } + resolved := resolvedTrackInfo{ + Title: actualTrack.Title, + ArtistName: providerArtist, + Duration: actualTrack.Duration, + } + if !trackMatchesRequest(req, resolved, logPrefix) { + // Invalidate the cached ID so future requests don't reuse it. + if req.ISRC != "" { + GetTrackIDCache().SetTidal(req.ISRC, 0) + } + return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'", + trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title) + } + GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title) + } + track := &TidalTrack{ ID: trackID, Title: strings.TrimSpace(req.TrackName), diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 039ff434..22b0eebd 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } + +// ==================== Shared Track Verification ==================== + +// resolvedTrackInfo holds the metadata fetched from a provider for verification. +type resolvedTrackInfo struct { + Title string + ArtistName string + Duration int // seconds +} + +// trackMatchesRequest checks whether a resolved track from a provider matches +// the original download request. Returns true if the track is a plausible match. +func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { + if req.ArtistName != "" && resolved.ArtistName != "" && + !artistsMatch(req.ArtistName, resolved.ArtistName) { + GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", + logPrefix, req.ArtistName, resolved.ArtistName) + return false + } + + if req.TrackName != "" && resolved.Title != "" && + !titlesMatch(req.TrackName, resolved.Title) { + GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n", + logPrefix, req.TrackName, resolved.Title) + return false + } + + expectedDurationSec := req.DurationMS / 1000 + if expectedDurationSec > 0 && resolved.Duration > 0 { + diff := expectedDurationSec - resolved.Duration + if diff < 0 { + diff = -diff + } + if diff > 10 { + GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n", + logPrefix, expectedDurationSec, resolved.Duration) + return false + } + } + + return true +} diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 9b8da6a4..b54fb92a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -81,7 +81,6 @@ class _AlbumScreenState extends ConsumerState { _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { - // Use extensionId if available, otherwise detect from albumId prefix final providerId = widget.extensionId ?? (() { @@ -134,9 +133,7 @@ class _AlbumScreenState extends ConsumerState { return (mediaSize.height * 0.55).clamp(360.0, 520.0); } - /// Upgrade cover URL to a reasonable resolution for full-screen display. - /// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate). - /// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800). + /// Upgrade cover URL to a higher resolution for full-screen display. String? _highResCoverUrl(String? url) { if (url == null) return null; // Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000) @@ -519,7 +516,6 @@ class _AlbumScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -575,7 +571,7 @@ class _AlbumScreenState extends ConsumerState { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - // Filter out tracks already in download history or local library + // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 67d377b9..c2d9bff3 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -350,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState { } Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { - // Info is now displayed in the full-screen cover overlay return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -609,7 +608,7 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; - // Filter out tracks already in download history or local library + // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)