From 55331b5fd92c3534a1e75f7ebfb1dd537f7efb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 14 Mar 2026 00:07:21 -0400 Subject: [PATCH] fix(scanner): prevent duplicate tracks when multiple missing files match same target (#5183) In processMissingTracks, matched tracks were not removed from the candidate pool after being consumed by moveMatched. This allowed the same target track to be paired with multiple missing tracks, creating duplicate non-missing records with the same path. Track consumed matches in a usedMatched map so each target is used at most once. Fixes #5169 --- scanner/phase_2_missing_tracks.go | 12 +++++++++- scanner/phase_2_missing_tracks_test.go | 33 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index c47565036..8c258b833 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -114,6 +114,10 @@ func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] { func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) { hasMatches := false + // Track which matched entries have already been consumed, so each matched track + // is only used once. Without this, the same matched track could be paired with + // multiple missing tracks, creating duplicate records with the same path. + usedMatched := make(map[string]bool, len(in.matched)) for _, ms := range in.missing { var exactMatch model.MediaFile @@ -121,6 +125,9 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr // Identify exact and equivalent matches for _, mt := range in.matched { + if usedMatched[mt.ID] { + continue + } if ms.Equals(mt) { exactMatch = mt break // Prioritize exact match @@ -138,13 +145,14 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[exactMatch.ID] = true p.totalMatched.Add(1) hasMatches = true continue } // If there is only one missing and one matched track, consider them equivalent (same PID) - if len(in.missing) == 1 && len(in.matched) == 1 { + if len(in.missing) == 1 && len(in.matched) == 1 && !usedMatched[in.matched[0].ID] { singleMatch := in.matched[0] log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name) err := p.moveMatched(singleMatch, ms) @@ -152,6 +160,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[singleMatch.ID] = true p.totalMatched.Add(1) hasMatches = true continue @@ -165,6 +174,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err) return nil, err } + usedMatched[equivalentMatch.ID] = true p.totalMatched.Add(1) hasMatches = true } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index fa6ef5724..d54ceee40 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -241,6 +241,39 @@ var _ = Describe("phaseMissingTracks", func() { Expect(movedTrack.Size).To(Equal(missingTrack.Size)) }) + It("should not match the same target to multiple missing tracks (prevents duplicate paths)", func() { + // Simulate a scenario where two missing tracks from different locations have the same + // base filename and match the same newly imported track via IsEquivalent. + // Without deduplication, both missing tracks would be "moved" to the same target, + // creating two non-missing records with the same path. + missingTrack1 := model.MediaFile{ID: "1", PID: "A", Path: "old_dir1/song.mp3", Title: "title1", Size: 100} + missingTrack2 := model.MediaFile{ID: "2", PID: "A", Path: "old_dir2/song.mp3", Title: "title1", Size: 100} + matchedTrack := model.MediaFile{ID: "3", PID: "A", Path: "new_dir/song.mp3", Title: "title1", Size: 200} + + _ = ds.MediaFile(ctx).Put(&missingTrack1) + _ = ds.MediaFile(ctx).Put(&missingTrack2) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack1, missingTrack2}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + // Only one of the missing tracks should be matched + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + // The matched track should have been consumed by the first missing track + movedTrack, _ := ds.MediaFile(ctx).Get("1") + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + + // The second missing track should remain unchanged + unmatchedTrack, _ := ds.MediaFile(ctx).Get("2") + Expect(unmatchedTrack.Path).To(Equal(missingTrack2.Path)) + }) + It("should return an error when there's an error moving the matched track", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}}