navidrome/scanner/phase_2_missing_tracks_test.go
ts c6732e1fdf
feat(cli): add missing file list and remap subcommands (#5928)
* feat(cli): add missing file list and remap subcommands

Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>

* fix: prevent remapping from dropping participants on target track

* fix: after remapping, refresh stats synchronously

* fix: only move album annotations if moving a track would empty the old album

* fix(persistence): keep the new item's annotation when reassigning onto an item the user already annotated

ReassignAnnotation was a plain UPDATE; the annotation table is unique on
(user_id, item_id, item_type), so when a user had annotated both items the
statement aborted and none of the rows moved. In the scanner that surfaced as
a warning; in the missing-file remap it rolled back the whole operation.
UPDATE OR IGNORE moves what it can and leaves the conflicting rows for GC.

* fix(core): keep the target track's history when remapping a missing file onto it

The remap discards the target's row, and GC then dropped its play counts,
stars, ratings, bookmarks and every playlist entry pointing at it. That is
harmless in the scanner, whose target was imported seconds earlier, but the
CLI lets the user pick any existing track. Move those references onto the
surviving id first; where a user already has a row for both, theirs on the
missing file wins.

* fix(persistence): stop FindByPaths dropping plain paths that contain a colon

Any colon was taken as the libraryID separator, and a non-numeric prefix
made the whole path vanish from the lookup. 'missing fix' then rejected the
very paths 'missing list' printed, and M3U imports silently skipped such
tracks. Only a numeric prefix qualifies a path now.

* perf(cli): stream 'missing list' instead of loading every missing file into memory

GetAll materialised the whole result set before a single row was written;
on a library with 97k missing files that peaked at 1.28 GB of RSS. Iterate
the repository cursor and write rows as they arrive.

* refactor(core): tidy the missing-file remap

Drop the log lines copied from deleteMissing that still said 'after deleting
missing files', the debug-on-success branches, and the what-comments; build
the affected album list without slice helpers.

* fix(cli): move path to the last column of 'missing list'

Path is the only variable-width field, so leading with it misaligns every
row that follows. Applies to both csv and json.

* fix(persistence): also try a numeric colon prefix as a plain path

'1999: A Different Life/01.mp3' parsed as library 1999 plus a truncated path
and matched nothing. The prefix is ambiguous, so search both ways.

Also buffer the json branch of 'missing list', which wrote a syscall per row.

* fix(persistence): move scrobbles and buffered scrobbles off a discarded media file

Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix'
deleting the target erased its play history and dropped scrobbles still
waiting on an external service. scrobble_buffer needs OR IGNORE for its
unique (user_id, service, media_file_id, play_time).

* fix(persistence): recompute the cached average rating after merging annotations

Merging the discarded row's annotations grows the rating population of the
surviving track, so media_file.average_rating no longer matched what the
annotation rows say. Only reachable since the remap started merging those
rows instead of deleting them.

* fix(persistence): recompute the cached average rating inside ReassignAnnotation

Moving annotation rows always changes the new item's rating population, so
the recompute belongs with the move rather than at each call site. Covers
the album reassign in the remap and the two scanner sites, and replaces the
explicit call ReassignReferences was making.

Album was the worse case: rate an album, move its files, and 'missing fix'
handed the rating to an album still caching an average of 0.

* fix(cli): let libraryID:path win over a file literally named like one

FindByPaths searches a numeric-prefixed reference both ways, so a top-level
file named '1:foo.mp3' can tie with library 1's 'foo.mp3'. The CLI then
rejected the reference as ambiguous while advising the exact syntax the
caller had used. Also disambiguates the same path in two libraries, which
is what the qualified form is for.

---------

Signed-off-by: zerovox <933064+zerovox@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-12 12:08:25 -04:00

959 lines
32 KiB
Go

package scanner
import (
"context"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("phaseMissingTracks", func() {
var (
phase *phaseMissingTracks
ctx context.Context
ds model.DataStore
mr *tests.MockMediaFileRepo
lr *tests.MockLibraryRepo
state *scanState
)
BeforeEach(func() {
ctx = context.Background()
mr = tests.CreateMockMediaFileRepo()
lr = &tests.MockLibraryRepo{}
lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}})
ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr}
state = &scanState{
libraries: model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}},
totalLibraryCount: 1,
}
phase = createPhaseMissingTracks(ctx, state, ds)
})
Describe("produceMissingTracks", func() {
var (
put func(tracks *missingTracks)
produced []*missingTracks
)
BeforeEach(func() {
produced = nil
put = func(tracks *missingTracks) {
produced = append(produced, tracks)
}
})
When("there are no missing tracks", func() {
It("should not call put", func() {
mr.SetData(model.MediaFiles{
{ID: "1", PID: "A", Missing: false},
{ID: "2", PID: "A", Missing: false},
})
err := phase.produce(put)
Expect(err).ToNot(HaveOccurred())
Expect(produced).To(BeEmpty())
})
})
When("there are missing tracks", func() {
It("should call put for any missing tracks with corresponding matches", func() {
mr.SetData(model.MediaFiles{
{ID: "1", PID: "A", Missing: true, LibraryID: 1},
{ID: "2", PID: "B", Missing: true, LibraryID: 1},
{ID: "3", PID: "A", Missing: false, LibraryID: 1},
})
err := phase.produce(put)
Expect(err).ToNot(HaveOccurred())
Expect(produced).To(HaveLen(2))
// PID A should have both missing and matched tracks
var pidA *missingTracks
for _, p := range produced {
if p.pid == "A" {
pidA = p
break
}
}
Expect(pidA).ToNot(BeNil())
Expect(pidA.missing).To(HaveLen(1))
Expect(pidA.matched).To(HaveLen(1))
// PID B should have only missing tracks
var pidB *missingTracks
for _, p := range produced {
if p.pid == "B" {
pidB = p
break
}
}
Expect(pidB).ToNot(BeNil())
Expect(pidB.missing).To(HaveLen(1))
Expect(pidB.matched).To(HaveLen(0))
})
It("should call put for any missing tracks even without matches", func() {
mr.SetData(model.MediaFiles{
{ID: "1", PID: "A", Missing: true, LibraryID: 1},
{ID: "2", PID: "B", Missing: true, LibraryID: 1},
{ID: "3", PID: "C", Missing: false, LibraryID: 1},
})
err := phase.produce(put)
Expect(err).ToNot(HaveOccurred())
Expect(produced).To(HaveLen(2))
// Both PID A and PID B should be produced even without matches
var pidA, pidB *missingTracks
for _, p := range produced {
if p.pid == "A" {
pidA = p
} else if p.pid == "B" {
pidB = p
}
}
Expect(pidA).ToNot(BeNil())
Expect(pidA.missing).To(HaveLen(1))
Expect(pidA.matched).To(HaveLen(0))
Expect(pidB).ToNot(BeNil())
Expect(pidB.missing).To(HaveLen(1))
Expect(pidB.matched).To(HaveLen(0))
})
})
})
Describe("processMissingTracks", func() {
It("should move the matched track when the missing track is the exact same", func() {
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
It("should move the matched track when the missing track has the same tags and filename", func() {
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
Expect(movedTrack.Size).To(Equal(matchedTrack.Size))
})
It("should move the matched track when there's only one missing track and one matched track (same PID)", func() {
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.flac", Tags: model.Tags{"title": []string{"different title"}}, Size: 200}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
Expect(movedTrack.Size).To(Equal(matchedTrack.Size))
})
It("should prioritize exact matches", func() {
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/file1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedEquivalent := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200}
matchedExact := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedEquivalent)
_ = ds.MediaFile(ctx).Put(&matchedExact)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
// Note that equivalent comes before the exact match
matched: []model.MediaFile{matchedEquivalent, matchedExact},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal(matchedExact.Path))
Expect(movedTrack.Size).To(Equal(matchedExact.Size))
})
It("should not move anything if there's more than one match and they don't are not exact nor equivalent", func() {
missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/file1.mp3", Title: "title1", Size: 100}
matched1 := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file2.flac", Title: "another title", Size: 200}
matched2 := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file3.mp3", Title: "different title", Size: 100}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matched1)
_ = ds.MediaFile(ctx).Put(&matched2)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matched1, matched2},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
// The missing track should still be the same
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal(missingTrack.Path))
Expect(movedTrack.Title).To(Equal(missingTrack.Title))
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"}}}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
// Simulate an error when moving the matched track by deleting the track from the DB
_ = ds.MediaFile(ctx).Delete("2")
_, err := phase.processMissingTracks(in)
Expect(err).To(HaveOccurred())
Expect(state.changesDetected.Load()).To(BeFalse())
})
})
Describe("finalize", func() {
It("should return nil if no error", func() {
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should return the error if provided", func() {
err := phase.finalize(context.DeadlineExceeded)
Expect(err).To(Equal(context.DeadlineExceeded))
Expect(state.changesDetected.Load()).To(BeFalse())
})
When("PurgeMissing is 'always'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
mr.SetCountAll(3)
mr.DeleteAllMissingValue = 3
})
It("should purge missing files", func() {
Expect(state.changesDetected.Load()).To(BeFalse())
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeTrue())
})
})
When("PurgeMissing is 'full'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
mr.SetCountAll(2)
mr.DeleteAllMissingValue = 2
})
It("should not purge missing files if not a full scan", func() {
state.fullScan = false
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should purge missing files if full scan", func() {
Expect(state.changesDetected.Load()).To(BeFalse())
state.fullScan = true
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeTrue())
})
})
When("PurgeMissing is 'never'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
mr.SetCountAll(1)
mr.DeleteAllMissingValue = 1
})
It("should not purge missing files", func() {
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
})
})
Describe("processCrossLibraryMoves", func() {
It("should skip processing if input is nil", func() {
result, err := phase.processCrossLibraryMoves(nil)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
It("should skip cross-library move detection when only one library is configured", func() {
// Default BeforeEach sets up single library, so we just need to verify skip behavior
Expect(state.totalLibraryCount).To(Equal(1))
missingTrack := model.MediaFile{
ID: "missing1",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-123",
Title: "Test Track",
Size: 1000,
Suffix: "mp3",
Path: "/lib1/track.mp3",
Missing: true,
CreatedAt: time.Now().Add(-30 * time.Minute),
}
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
// Should return input unchanged (no processing done)
Expect(result).To(Equal(in))
// No matches should be found since cross-library search was skipped
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
// No changes should be detected
Expect(state.changesDetected.Load()).To(BeFalse())
})
Context("with multiple libraries", func() {
BeforeEach(func() {
// Set up multiple libraries for cross-library move tests
state.libraries = model.Libraries{
{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)},
{ID: 2, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)},
}
state.totalLibraryCount = 2
})
It("should process cross-library moves using MusicBrainz Track ID", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing1",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-123",
Title: "Test Track",
Size: 1000,
Suffix: "mp3",
Path: "/lib1/track.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
movedTrack := model.MediaFile{
ID: "moved1",
LibraryID: 2,
MbzReleaseTrackID: "mbz-track-123",
Title: "Test Track",
Size: 1000,
Suffix: "mp3",
Path: "/lib2/track.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&movedTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the move was performed
updatedTrack, _ := ds.MediaFile(ctx).Get("missing1")
Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should fall back to intrinsic properties when MBZ Track ID is empty", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing2",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 2",
Size: 2000,
Suffix: "flac",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib1/track2.flac",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
movedTrack := model.MediaFile{
ID: "moved2",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 2",
Size: 2000,
Suffix: "flac",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib2/track2.flac",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&movedTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the move was performed
updatedTrack, _ := ds.MediaFile(ctx).Get("missing2")
Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should not match files in the same library", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing3",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-456",
Title: "Test Track 3",
Size: 3000,
Suffix: "mp3",
Path: "/lib1/track3.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
sameLibTrack := model.MediaFile{
ID: "same1",
LibraryID: 1, // Same library
MbzReleaseTrackID: "mbz-track-456",
Title: "Test Track 3",
Size: 3000,
Suffix: "mp3",
Path: "/lib1/other/track3.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&sameLibTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should prioritize MBZ Track ID over intrinsic properties", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing4",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-789",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
Path: "/lib1/track4.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Track with same MBZ ID
mbzTrack := model.MediaFile{
ID: "mbz1",
LibraryID: 2,
MbzReleaseTrackID: "mbz-track-789",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
Path: "/lib2/track4.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
// Track with same intrinsic properties but no MBZ ID
intrinsicTrack := model.MediaFile{
ID: "intrinsic1",
LibraryID: 3,
MbzReleaseTrackID: "",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib3/track4.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-5 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&mbzTrack)
_ = ds.MediaFile(ctx).Put(&intrinsicTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the MBZ track was chosen (not the intrinsic one)
updatedTrack, _ := ds.MediaFile(ctx).Get("missing4")
Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should handle equivalent matches correctly", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing5",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 5",
Size: 5000,
Suffix: "mp3",
Path: "/lib1/path/track5.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Equivalent match (same filename, different directory)
equivalentTrack := model.MediaFile{
ID: "equiv1",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 5",
Size: 5000,
Suffix: "mp3",
Path: "/lib2/different/track5.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&equivalentTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the equivalent match was accepted
updatedTrack, _ := ds.MediaFile(ctx).Get("missing5")
Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should skip matching when multiple matches are found but none are exact", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing6",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib1/track6.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Multiple matches with different metadata (not exact matches)
match1 := model.MediaFile{
ID: "match1",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib2/different_track.mp3",
Artist: "Different Artist", // This makes it non-exact
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
match2 := model.MediaFile{
ID: "match2",
LibraryID: 3,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib3/another_track.mp3",
Artist: "Another Artist", // This makes it non-exact
Missing: false,
CreatedAt: scanStartTime.Add(-5 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&match1)
_ = ds.MediaFile(ctx).Put(&match2)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
// Verify no move was performed
unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6")
Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3"))
Expect(unchangedTrack.LibraryID).To(Equal(1))
})
It("should handle errors gracefully", func() {
// Set up mock to return error
mr.Err = true
missingTrack := model.MediaFile{
ID: "missing7",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-error",
Title: "Test Track 7",
Size: 7000,
Suffix: "mp3",
Path: "/lib1/track7.mp3",
Missing: true,
CreatedAt: time.Now().Add(-30 * time.Minute),
}
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
// Should not fail completely, just skip the problematic file
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
})
}) // End of Context "with multiple libraries"
})
Describe("CreatedAt preservation (#5050)", func() {
var albumRepo *tests.MockAlbumRepo
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
albumRepo.CopyAttributesCalls = make(map[string]string)
})
It("should preserve the missing track's created_at when moving within a library", func() {
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "1", PID: "A", Path: "old/song.mp3",
AlbumID: "album-1",
LibraryID: 1,
CreatedAt: originalTime,
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
matchedTrack := model.MediaFile{
ID: "2", PID: "A", Path: "new/song.mp3",
AlbumID: "album-1", // Same album
LibraryID: 1,
CreatedAt: time.Now(), // Much newer
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
})
It("should preserve created_at during cross-library moves with album change", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
AlbumID: "old-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
AlbumID: "new-album",
LibraryID: 2,
CreatedAt: time.Now(),
}
// Set up albums so CopyAttributes can find them
albumRepo.SetData(model.Albums{
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
})
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should be preserved from the missing file
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// Album's created_at should be copied from old to new
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
// Verify the new album's CreatedAt was actually updated
newAlbum, err := albumRepo.Get("new-album")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
})
It("should not copy album created_at when album ID does not change", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
AlbumID: "same-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
AlbumID: "same-album", // Same album
LibraryID: 1,
CreatedAt: time.Now(),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should still be preserved
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// CopyAttributes should NOT have been called (same album)
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
})
})
Describe("Album Annotation Reassignment", func() {
var (
albumRepo *tests.MockAlbumRepo
missingTrack model.MediaFile
matchedTrack model.MediaFile
oldAlbumID string
newAlbumID string
)
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
oldAlbumID = "old-album-id"
newAlbumID = "new-album-id"
missingTrack = model.MediaFile{
ID: "missing-track-id",
PID: "same-pid",
Path: "old/path.mp3",
AlbumID: oldAlbumID,
LibraryID: 1,
Missing: true,
Annotations: model.Annotations{
PlayCount: 5,
Rating: 4,
Starred: true,
},
}
matchedTrack = model.MediaFile{
ID: "matched-track-id",
PID: "same-pid",
Path: "new/path.mp3",
AlbumID: newAlbumID,
LibraryID: 2, // Different library
Missing: false,
Annotations: model.Annotations{
PlayCount: 2,
Rating: 3,
Starred: false,
},
}
// Store both tracks in the database
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
})
When("album ID changes during cross-library move", func() {
It("should reassign album annotations when AlbumID changes", func() {
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that ReassignAnnotation was called
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID))
})
It("should not reassign annotations when AlbumID is the same", func() {
missingTrack.AlbumID = newAlbumID // Same album
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that ReassignAnnotation was NOT called
Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
})
})
When("error handling", func() {
It("should handle ReassignAnnotation errors gracefully", func() {
// Make the album repo return an error
albumRepo.SetError(true)
// The move should still succeed even if annotation reassignment fails
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that the track was still moved (ID should be updated)
movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID)
Expect(err).ToNot(HaveOccurred())
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
})
})
})