package scanner
import (
"context"
"errors"
"maps"
"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))
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))
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))
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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().Get(ctx, "1")
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
Context("claiming the album annotation reassignment", func() {
var probe *probeTxDS
missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
BeforeEach(func() {
probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)}
probe.MockedAlbum = tests.CreateMockAlbumRepo()
phase = createPhaseMissingTracks(ctx, state, probe)
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
})
It("claims the target album before the transaction, so a concurrent move skips it", func() {
probe.during = func() {
phase.annotationMutex.RLock()
defer phase.annotationMutex.RUnlock()
Expect(phase.processedAlbumAnnotations).To(HaveKeyWithValue("new-album", true))
}
Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed())
})
It("releases the claim when the move fails, so a later move can reassign", func() {
probe.err = errors.New("boom")
Expect(phase.moveMatched(matchedTrack, missingTrack)).To(MatchError("boom"))
Expect(phase.processedAlbumAnnotations).ToNot(HaveKey("new-album"))
})
})
Context("when the move transaction is rerun after a busy rollback", func() {
var rerunDS *rerunTxDS
BeforeEach(func() {
rerunDS = &rerunTxDS{MockDataStore: ds.(*tests.MockDataStore)}
rerunDS.snapshot = func() func() {
saved := maps.Clone(mr.Data)
return func() { mr.Data = saved }
}
phase = createPhaseMissingTracks(ctx, state, rerunDS)
})
It("keeps the moved track", 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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
_, err := phase.processMissingTracks(&missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
})
Expect(err).ToNot(HaveOccurred())
movedTrack, err := ds.MediaFile().Get(ctx, "1")
Expect(err).ToNot(HaveOccurred())
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
It("reassigns the album annotations in the attempt that commits", func() {
albumRepo := tests.CreateMockAlbumRepo()
rerunDS.MockedAlbum = albumRepo
restoreTracks := rerunDS.snapshot
rerunDS.snapshot = func() func() {
restore := restoreTracks()
return func() {
restore()
albumRepo.ReassignAnnotationCalls = nil
}
}
missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100}
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
_, err := phase.processMissingTracks(&missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
})
Expect(err).ToNot(HaveOccurred())
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("old-album", "new-album"))
})
})
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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().Get(ctx, "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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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().Get(ctx, "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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedEquivalent)
_ = ds.MediaFile().Put(ctx, &matchedExact)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
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().Get(ctx, "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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matched1)
_ = ds.MediaFile().Put(ctx, &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())
movedTrack, _ := ds.MediaFile().Get(ctx, "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() {
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().Put(ctx, &missingTrack1)
_ = ds.MediaFile().Put(ctx, &missingTrack2)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack1, missingTrack2},
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().Get(ctx, "1")
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
unmatchedTrack, _ := ds.MediaFile().Get(ctx, "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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_ = ds.MediaFile().Delete(ctx, "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() {
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())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
})
Context("with multiple libraries", func() {
BeforeEach(func() {
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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())
updatedTrack, _ := ds.MediaFile().Get(ctx, "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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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())
updatedTrack, _ := ds.MediaFile().Get(ctx, "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,
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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),
}
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),
}
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &mbzTrack)
_ = ds.MediaFile().Put(ctx, &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())
updatedTrack, _ := ds.MediaFile().Get(ctx, "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),
}
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().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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())
updatedTrack, _ := ds.MediaFile().Get(ctx, "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),
}
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",
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",
Missing: false,
CreatedAt: scanStartTime.Add(-5 * time.Minute),
}
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &match1)
_ = ds.MediaFile().Put(ctx, &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())
unchangedTrack, _ := ds.MediaFile().Get(ctx, "missing6")
Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3"))
Expect(unchangedTrack.LibraryID).To(Equal(1))
})
It("should handle errors gracefully", func() {
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},
}
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())
})
})
})
Describe("CreatedAt preservation (#5050)", func() {
var albumRepo *tests.MockAlbumRepo
BeforeEach(func() {
albumRepo = ds.Album().(*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",
LibraryID: 1,
CreatedAt: time.Now(),
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile().Get(ctx, "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(),
}
albumRepo.SetData(model.Albums{
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
})
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile().Get(ctx, "missing-ca")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
newAlbum, err := albumRepo.Get(ctx, "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",
LibraryID: 1,
CreatedAt: time.Now(),
}
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile().Get(ctx, "missing-same")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
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().(*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,
Missing: false,
Annotations: model.Annotations{
PlayCount: 2,
Rating: 3,
Starred: false,
},
}
_ = ds.MediaFile().Put(ctx, &missingTrack)
_ = ds.MediaFile().Put(ctx, &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())
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID))
})
It("should not reassign annotations when AlbumID is the same", func() {
missingTrack.AlbumID = newAlbumID
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
})
})
When("error handling", func() {
It("should handle ReassignAnnotation errors gracefully", func() {
albumRepo.SetError(true)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
movedTrack, err := ds.MediaFile().Get(ctx, missingTrack.ID)
Expect(err).ToNot(HaveOccurred())
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
})
})
})
type rerunTxDS struct {
*tests.MockDataStore
snapshot func() (rollback func())
}
func (d *rerunTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error {
rollback := d.snapshot()
_ = block(ctx, d.MockDataStore)
rollback()
return block(ctx, d.MockDataStore)
}
type probeTxDS struct {
*tests.MockDataStore
during func()
err error
}
func (d *probeTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error {
if err := block(ctx, d.MockDataStore); err != nil {
return err
}
if d.during != nil {
d.during()
}
return d.err
}