update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
769
scanner/phase_2_missing_tracks_test.go
Normal file
769
scanner/phase_2_missing_tracks_test.go
Normal file
@@ -0,0 +1,769 @@
|
||||
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)}},
|
||||
}
|
||||
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 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.CountAllValue = 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.CountAllValue = 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.CountAllValue = 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 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())
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user