diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go new file mode 100644 index 000000000..6ee9ae112 --- /dev/null +++ b/scanner/scanner_multilibrary_test.go @@ -0,0 +1,509 @@ +package scanner_test + +import ( + "context" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Scanner - Multi-Library", Ordered, func() { + var ctx context.Context + var lib1, lib2 model.Library + var ds *tests.MockDataStore + var s scanner.Scanner + + createFS := func(path string, files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register(path, &fs) + return fs + } + + BeforeAll(func() { + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) + tmpDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL") + log.Warn("Using DB at " + conf.Server.DbPath) + db.Db().SetMaxOpenConns(1) + }) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevExternalScanner = false + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Create the admin user in the database to match the context + adminUser := model.User{ + ID: "123", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "password", + } + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + + // Create two test libraries (let DB auto-assign IDs) + lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} + lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"} + Expect(ds.Library(ctx).Put(&lib1)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) + }) + + runScanner := func(ctx context.Context, fullScan bool) error { + _, err := s.ScanAll(ctx, fullScan) + return err + } + + Context("Two Libraries with Different Content", func() { + BeforeEach(func() { + // Rock library content + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + zeppelin := template(_t{"albumartist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), + "Led Zeppelin/IV/01 - Black Dog.mp3": zeppelin(track(1, "Black Dog")), + "Led Zeppelin/IV/02 - Rock and Roll.mp3": zeppelin(track(2, "Rock and Roll")), + }) + + // Jazz library content + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"}) + + _ = createFS("jazz", fstest.MapFS{ + "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), + "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), + "John Coltrane/Giant Steps/01 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), + "John Coltrane/Giant Steps/02 - Cousin Mary.mp3": coltrane(track(2, "Cousin Mary")), + }) + }) + + When("scanning both libraries", func() { + It("should import files with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library media files + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(4)) + + rockTitles := slice.Map(rockFiles, func(f model.MediaFile) string { return f.Title }) + Expect(rockTitles).To(ContainElements("Come Together", "Something", "Black Dog", "Rock and Roll")) + + // Verify all rock files have correct library_id + for _, mf := range rockFiles { + Expect(mf.LibraryID).To(Equal(lib1.ID), "Rock file %s should have library_id %d", mf.Title, lib1.ID) + } + + // Check Jazz library media files + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + Sort: "title", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(4)) + + jazzTitles := slice.Map(jazzFiles, func(f model.MediaFile) string { return f.Title }) + Expect(jazzTitles).To(ContainElements("So What", "Freddie Freeloader", "Giant Steps", "Cousin Mary")) + + // Verify all jazz files have correct library_id + for _, mf := range jazzFiles { + Expect(mf.LibraryID).To(Equal(lib2.ID), "Jazz file %s should have library_id %d", mf.Title, lib2.ID) + } + }) + + It("should create albums with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library albums + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockAlbums).To(HaveLen(2)) + Expect(rockAlbums[0].Name).To(Equal("Abbey Road")) + Expect(rockAlbums[0].LibraryID).To(Equal(lib1.ID)) + Expect(rockAlbums[0].SongCount).To(Equal(2)) + Expect(rockAlbums[1].Name).To(Equal("IV")) + Expect(rockAlbums[1].LibraryID).To(Equal(lib1.ID)) + Expect(rockAlbums[1].SongCount).To(Equal(2)) + + // Check Jazz library albums + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + Sort: "name", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzAlbums).To(HaveLen(2)) + Expect(jazzAlbums[0].Name).To(Equal("Giant Steps")) + Expect(jazzAlbums[0].LibraryID).To(Equal(lib2.ID)) + Expect(jazzAlbums[0].SongCount).To(Equal(2)) + Expect(jazzAlbums[1].Name).To(Equal("Kind of Blue")) + Expect(jazzAlbums[1].LibraryID).To(Equal(lib2.ID)) + Expect(jazzAlbums[1].SongCount).To(Equal(2)) + }) + + It("should create folders with correct library_id", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library folders + rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFolders).To(HaveLen(5)) // ., The Beatles, Led Zeppelin, Abbey Road, IV + + for _, folder := range rockFolders { + Expect(folder.LibraryID).To(Equal(lib1.ID), "Rock folder %s should have library_id %d", folder.Name, lib1.ID) + } + + // Check Jazz library folders + jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFolders).To(HaveLen(5)) // ., Miles Davis, John Coltrane, Kind of Blue, Giant Steps + + for _, folder := range jazzFolders { + Expect(folder.LibraryID).To(Equal(lib2.ID), "Jazz folder %s should have library_id %d", folder.Name, lib2.ID) + } + }) + + It("should create library-artist associations correctly", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check library-artist associations + + // Get all artists and check library associations + allArtists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + + rockArtistNames := []string{} + jazzArtistNames := []string{} + + for _, artist := range allArtists { + // Check if artist is associated with rock library + var count int64 + err := db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib1.ID, artist.ID, + ).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + if count > 0 { + rockArtistNames = append(rockArtistNames, artist.Name) + } + + // Check if artist is associated with jazz library + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib2.ID, artist.ID, + ).Scan(&count) + Expect(err).ToNot(HaveOccurred()) + if count > 0 { + jazzArtistNames = append(jazzArtistNames, artist.Name) + } + } + + Expect(rockArtistNames).To(ContainElements("The Beatles", "Led Zeppelin")) + Expect(jazzArtistNames).To(ContainElements("Miles Davis", "John Coltrane")) + + // Artists should not be shared between libraries (except [Unknown Artist]) + for _, name := range rockArtistNames { + if name != "[Unknown Artist]" { + Expect(jazzArtistNames).ToNot(ContainElement(name)) + } + } + }) + + It("should update library statistics correctly", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Check Rock library stats + rockLib, err := ds.Library(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(4)) + Expect(rockLib.TotalAlbums).To(Equal(2)) + Expect(rockLib.TotalArtists).To(BeNumerically(">=", 2)) + Expect(rockLib.TotalFolders).To(BeNumerically(">=", 2)) + + // Check Jazz library stats + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(4)) + Expect(jazzLib.TotalAlbums).To(Equal(2)) + Expect(jazzLib.TotalArtists).To(BeNumerically(">=", 2)) + Expect(jazzLib.TotalFolders).To(BeNumerically(">=", 2)) + }) + }) + + When("libraries have different content", func() { + It("should maintain separate statistics per library", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify rock library stats + rockLib, err := ds.Library(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(4)) + Expect(rockLib.TotalAlbums).To(Equal(2)) + + // Verify jazz library stats + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(4)) + Expect(jazzLib.TotalAlbums).To(Equal(2)) + + // Verify that libraries don't interfere with each other + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(4)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(4)) + }) + }) + + When("verifying library isolation", func() { + It("should keep library data completely separate", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify that rock library only contains rock content + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + rockAlbumNames := slice.Map(rockAlbums, func(a model.Album) string { return a.Name }) + Expect(rockAlbumNames).To(ContainElements("Abbey Road", "IV")) + Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps")) + + // Verify that jazz library only contains jazz content + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + jazzAlbumNames := slice.Map(jazzAlbums, func(a model.Album) string { return a.Name }) + Expect(jazzAlbumNames).To(ContainElements("Kind of Blue", "Giant Steps")) + Expect(jazzAlbumNames).ToNot(ContainElements("Abbey Road", "IV")) + }) + }) + + When("same artist appears in different libraries", func() { + It("should associate artist with both libraries correctly", func() { + // Create libraries with Jeff Beck albums in both + jeffRock := template(_t{"albumartist": "Jeff Beck", "album": "Truth", "year": 1968, "genre": "Rock"}) + jeffJazz := template(_t{"albumartist": "Jeff Beck", "album": "Blow by Blow", "year": 1975, "genre": "Jazz"}) + beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + + // Create rock library with Jeff Beck's Truth album + _ = createFS("rock", fstest.MapFS{ + "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), + "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), + "Jeff Beck/Truth/01 - Beck's Bolero.mp3": jeffRock(track(1, "Beck's Bolero")), + "Jeff Beck/Truth/02 - Ol' Man River.mp3": jeffRock(track(2, "Ol' Man River")), + }) + + // Create jazz library with Jeff Beck's Blow by Blow album + _ = createFS("jazz", fstest.MapFS{ + "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), + "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), + "Jeff Beck/Blow by Blow/01 - You Know What I Mean.mp3": jeffJazz(track(1, "You Know What I Mean")), + "Jeff Beck/Blow by Blow/02 - She's a Woman.mp3": jeffJazz(track(2, "She's a Woman")), + }) + + Expect(runScanner(ctx, true)).To(Succeed()) + + // Jeff Beck should be associated with both libraries + var rockCount, jazzCount int64 + + // Get Jeff Beck artist ID + jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jeffArtists).To(HaveLen(1)) + jeffID := jeffArtists[0].ID + + // Check rock library association + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib1.ID, jeffID, + ).Scan(&rockCount) + Expect(err).ToNot(HaveOccurred()) + Expect(rockCount).To(Equal(int64(1))) + + // Check jazz library association + err = db.Db().QueryRow( + "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", + lib2.ID, jeffID, + ).Scan(&jazzCount) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzCount).To(Equal(int64(1))) + + // Verify Jeff Beck albums are in correct libraries + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockAlbums).To(HaveLen(1)) + Expect(rockAlbums[0].Name).To(Equal("Truth")) + + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzAlbums).To(HaveLen(1)) + Expect(jazzAlbums[0].Name).To(Equal("Blow by Blow")) + }) + }) + }) + + Context("Incremental Scan Behavior", func() { + BeforeEach(func() { + // Start with minimal content in both libraries + rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"}) + jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"}) + + createFS("rock", fstest.MapFS{ + "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")), + }) + + createFS("jazz", fstest.MapFS{ + "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")), + }) + }) + + It("should handle incremental scans per library correctly", func() { + // Initial full scan + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify initial state + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + + // Incremental scan should not duplicate existing files + Expect(runScanner(ctx, false)).To(Succeed()) + + // Verify counts remain the same + rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib2.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzFiles).To(HaveLen(1)) + }) + }) + + Context("Missing Files Handling", func() { + var rockFS storagetest.FakeFS + + BeforeEach(func() { + rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) + + rockFS = createFS("rock", fstest.MapFS{ + "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), + "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")), + }) + + createFS("jazz", fstest.MapFS{ + "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": template(_t{ + "albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz", + })(track(1, "Chameleon")), + }) + }) + + It("should mark missing files correctly per library", func() { + // Initial scan + Expect(runScanner(ctx, true)).To(Succeed()) + + // Remove one file from rock library only + rockFS.Remove("AC-DC/Back in Black/02 - Shoot to Thrill.mp3") + + // Rescan + Expect(runScanner(ctx, false)).To(Succeed()) + + // Check that only the rock library file is marked as missing + missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib1.ID}, + squirrel.Eq{"missing": true}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(missingRockFiles).To(HaveLen(1)) + Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill")) + + // Check that jazz library files are not affected + missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib2.ID}, + squirrel.Eq{"missing": true}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(missingJazzFiles).To(HaveLen(0)) + + // Verify non-missing files + presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"library_id": lib1.ID}, + squirrel.Eq{"missing": false}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(presentRockFiles).To(HaveLen(1)) + Expect(presentRockFiles[0].Title).To(Equal("Hells Bells")) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 02a03e56e..56f68a74b 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -27,6 +27,7 @@ type MockDataStore struct { MockedScrobbleBuffer model.ScrobbleBufferRepository MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex + repoMu sync.Mutex } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -85,6 +86,8 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedMediaFile == nil { if db.RealDS != nil { db.MockedMediaFile = db.RealDS.MediaFile(ctx)