From 385afe290aec15316f16924f6834eab312ae23dc Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 14:15:44 +0700 Subject: [PATCH] fix(download): reject mismatched provider tracks --- go_backend/extension_fallback.go | 17 ++++++++++ go_backend/extension_fallback_helpers.go | 35 ++++++++++++++++++++ go_backend/extension_goja_convert.go | 1 + go_backend/extension_provider_types.go | 1 + go_backend/extension_providers_test.go | 41 ++++++++++++++++++++++++ go_backend/title_match_utils.go | 8 +++++ go_backend/title_match_utils_test.go | 36 +++++++++++++++++++++ 7 files changed, 139 insertions(+) diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index 591fbce7..1c0e7896 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -43,6 +43,23 @@ func attemptExtensionDownload( } }) downloadSucceeded := err == nil && result != nil && result.Success + if downloadSucceeded { + resolved := resolvedTrackInfo{ + Title: result.Title, + ArtistName: result.Artist, + AlbumName: result.Album, + ISRC: result.ISRC, + Duration: result.DurationMS / 1000, + SkipNameVerification: strings.EqualFold(strings.TrimSpace(req.Source), strings.TrimSpace(providerLabel)) || + ext.Manifest.HasCustomMatching(), + } + if !trackMatchesRequest(req, resolved, "Extension "+providerLabel) { + discardRejectedExtensionOutput(result, outputPath) + *lastErr = fmt.Errorf("provider %s returned a different track", providerLabel) + *lastErrType = "not_found" + return nil, false + } + } if req.ItemID != "" && downloadSucceeded { SetItemFinalizing(req.ItemID) } diff --git a/go_backend/extension_fallback_helpers.go b/go_backend/extension_fallback_helpers.go index 4533cb9c..7605733a 100644 --- a/go_backend/extension_fallback_helpers.go +++ b/go_backend/extension_fallback_helpers.go @@ -3,6 +3,8 @@ package gobackend import ( "errors" "fmt" + "os" + "path/filepath" "strings" ) @@ -132,6 +134,39 @@ func normalizeDownloadResultExtension(candidates ...string) string { return "" } +// discardRejectedExtensionOutput removes only a newly downloaded file inside +// the host-selected output directory. Existing-library hits are never removed, +// nor are paths outside that narrow directory. +func discardRejectedExtensionOutput(result *ExtDownloadResult, requestedOutputPath string) { + if result == nil || result.AlreadyExists { + return + } + + resultPath := strings.TrimSpace(result.FilePath) + requestedPath := strings.TrimSpace(requestedOutputPath) + if resultPath == "" || requestedPath == "" || + strings.HasPrefix(resultPath, "content://") || + strings.HasPrefix(resultPath, "/proc/self/fd/") { + return + } + + resultAbs, resultErr := filepath.Abs(resultPath) + outputDirAbs, outputErr := filepath.Abs(filepath.Dir(requestedPath)) + if resultErr != nil || outputErr != nil { + return + } + relative, err := filepath.Rel(outputDirAbs, resultAbs) + if err != nil || relative == "." || relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) || + filepath.IsAbs(relative) { + return + } + + if err := os.Remove(resultAbs); err != nil && !os.IsNotExist(err) { + GoLog("[DownloadWithExtensionFallback] Warning: failed to remove rejected provider output %q: %v\n", resultAbs, err) + } +} + func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { if result == nil { return DownloadResult{}, false diff --git a/go_backend/extension_goja_convert.go b/go_backend/extension_goja_convert.go index dc2ed3bf..4c87116e 100644 --- a/go_backend/extension_goja_convert.go +++ b/go_backend/extension_goja_convert.go @@ -485,6 +485,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), + DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), ErrorType: gojaObjectString(obj, "error_type", "errorType"), RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"), diff --git a/go_backend/extension_provider_types.go b/go_backend/extension_provider_types.go index bcb5dbe3..02d1d944 100644 --- a/go_backend/extension_provider_types.go +++ b/go_backend/extension_provider_types.go @@ -107,6 +107,7 @@ type ExtDownloadResult struct { BitDepth int `json:"bit_depth,omitempty"` SampleRate int `json:"sample_rate,omitempty"` AudioCodec string `json:"audio_codec,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,omitempty"` RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 9ad82ec0..1b451895 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -471,6 +471,45 @@ func TestMoveProviderToFrontPreservesExplicitSelection(t *testing.T) { } } +func TestDiscardRejectedExtensionOutputStaysInsideRequestedDirectory(t *testing.T) { + outputDir := t.TempDir() + requestedPath := filepath.Join(outputDir, "Artist - Song.flac") + rejectedPath := filepath.Join(outputDir, "Artist - Song.m4a") + outsidePath := filepath.Join(t.TempDir(), "keep.flac") + if err := os.WriteFile(rejectedPath, []byte("wrong audio"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(outsidePath, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + + discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: rejectedPath}, requestedPath) + if _, err := os.Stat(rejectedPath); !os.IsNotExist(err) { + t.Fatalf("rejected output was not removed: %v", err) + } + + discardRejectedExtensionOutput(&ExtDownloadResult{FilePath: outsidePath}, requestedPath) + if _, err := os.Stat(outsidePath); err != nil { + t.Fatalf("output outside requested directory was removed: %v", err) + } +} + +func TestDiscardRejectedExtensionOutputPreservesExistingLibraryHit(t *testing.T) { + outputDir := t.TempDir() + path := filepath.Join(outputDir, "existing.flac") + if err := os.WriteFile(path, []byte("existing audio"), 0o600); err != nil { + t.Fatal(err) + } + + discardRejectedExtensionOutput(&ExtDownloadResult{ + FilePath: path, + AlreadyExists: true, + }, filepath.Join(outputDir, "requested.flac")) + if _, err := os.Stat(path); err != nil { + t.Fatalf("existing library file was removed: %v", err) + } +} + func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) { resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{ Reason: "direct SoundCloud track ID", @@ -685,6 +724,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) { alreadyExists: true, bitDepth: 24, sampleRate: 96000, + durationMs: 181000, title: "Song", albumArtist: "Album Artist", lyricsLrc: "[00:00.00]Line", @@ -706,6 +746,7 @@ func TestParseExtensionMetadataAndDownloadResults(t *testing.T) { !download.AlreadyExists || download.BitDepth != 24 || download.SampleRate != 96000 || + download.DurationMS != 181000 || download.AlbumArtist != "Album Artist" || download.LyricsLRC != "[00:00.00]Line" || download.Decryption == nil || diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 52ecaeb4..d7564050 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -363,6 +363,7 @@ func isLatinScript(value string) bool { type resolvedTrackInfo struct { Title string ArtistName string + AlbumName string ISRC string Duration int SkipNameVerification bool @@ -387,6 +388,13 @@ func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPre logPrefix, req.TrackName, resolved.Title) return false } + + if req.AlbumName != "" && resolved.AlbumName != "" && + !titlesMatch(req.AlbumName, resolved.AlbumName) { + GoLog("[%s] Verification failed: album mismatch — expected '%s', got '%s'\n", + logPrefix, req.AlbumName, resolved.AlbumName) + return false + } } expectedDurationSec := req.DurationMS / 1000 diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index 74c46f90..c41f8286 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -55,6 +55,42 @@ func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) { } } +func TestTrackMatchesRequestRejectsDifferentAlbumWithoutExactISRC(t *testing.T) { + req := DownloadRequest{ + TrackName: "Bewafa", + ArtistName: "Imran Khan", + AlbumName: "Unforgettable", + ISRC: "GBUM70901234", + } + resolved := resolvedTrackInfo{ + Title: "Bewafa", + ArtistName: "Imran Khan, Tarandeep Singh", + AlbumName: "Bewafa", + ISRC: "QZXYZ2600001", + } + + if trackMatchesRequest(req, resolved, "test") { + t.Fatal("expected same-title cover from a different album to be rejected") + } +} + +func TestTrackMatchesRequestAcceptsDifferentEditionWithExactISRC(t *testing.T) { + req := DownloadRequest{ + TrackName: "Song", + AlbumName: "Original Album", + ISRC: "USRC17607839", + } + resolved := resolvedTrackInfo{ + Title: "Song", + AlbumName: "Deluxe Collection", + ISRC: "usrc17607839", + } + + if !trackMatchesRequest(req, resolved, "test") { + t.Fatal("expected an exact ISRC match to accept another release edition") + } +} + func TestTitlesMatch_SeparatorVariants(t *testing.T) { if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { t.Fatal("expected tidal titlesMatch to accept / vs _ variant")