🏡 index : ironmagma/quintodrome.git

package scanner_test

import (
	"context"
	"errors"
	"path/filepath"
	"testing/fstest"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/artwork"
	"github.com/navidrome/navidrome/core/metrics"
	"github.com/navidrome/navidrome/core/playlists"
	"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 model.Scanner

	createFS := func(path string, files fstest.MapFS) storagetest.FakeFS {
		fs := storagetest.FakeFS{}
		fs.SetFiles(files)
		storagetest.Register(path, &fs)
		return fs
	}

	BeforeAll(func() {
		tests.SkipOnWindows("SQLite file lock blocks TempDir cleanup (#TBD-path-sep-scanner)")
		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.MusicFolder = "default:///music" // Use a distinct schema for the default library
		conf.Server.DevExternalScanner = false

		// Register an empty fake storage for the default library
		emptyFS := storagetest.FakeFS{}
		emptyFS.SetFiles(fstest.MapFS{})
		storagetest.Register("default", &emptyFS)

		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(),
			playlists.NewPlaylists(ds, core.NewImageUploadService()), 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(Equal(3)) // The Beatles, Led Zeppelin, [Unknown Artist]
				Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files)

				// 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(Equal(3)) // Miles Davis, John Coltrane, [Unknown Artist]
				Expect(jazzLib.TotalFolders).To(Equal(2)) // Kind of Blue, Giant Steps (only folders with audio files)
			})
		})

		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"))
		})
	})

	Context("Error Handling - Multi-Library", func() {
		Context("Filesystem errors affecting one library", func() {
			var rockFS storagetest.FakeFS

			BeforeEach(func() {
				// Set up content for both libraries
				rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
				jazz := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})

				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{
					"Miles Davis/Kind of Blue/01 - So What.mp3":            jazz(track(1, "So What")),
					"Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": jazz(track(2, "Freddie Freeloader")),
				})
			})

			It("should not affect scanning of other libraries", func() {
				// Inject filesystem read error in rock library only
				rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("filesystem read error"))

				// Scan should succeed overall and return warnings
				warnings, err := s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred())
				Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors")

				// Jazz library should have been scanned successfully
				jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib2.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzFiles).To(HaveLen(2))
				Expect(jazzFiles[0].Title).To(BeElementOf("So What", "Freddie Freeloader"))
				Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader"))

				// Rock library may have partial content (depending on scanner implementation)
				rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib1.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				// No specific expectation - some files may have been imported despite errors
				_ = rockFiles

				// Verify jazz library stats are correct
				jazzLib, err := ds.Library(ctx).Get(lib2.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzLib.TotalSongs).To(Equal(2))

				// Error should be empty (warnings don't count as scan errors)
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(BeEmpty())
			})

			It("should continue with warnings for affected library", func() {
				// Inject read errors on multiple files in rock library
				rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("read error 1"))
				rockFS.SetError("AC-DC/Back in Black/02 - Shoot to Thrill.mp3", errors.New("read error 2"))

				// Scan should complete with warnings for multiple filesystem errors
				warnings, err := s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred())
				Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors")

				// Jazz library should be completely unaffected
				jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib2.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzFiles).To(HaveLen(2))

				// Jazz library statistics should be accurate
				jazzLib, err := ds.Library(ctx).Get(lib2.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzLib.TotalSongs).To(Equal(2))
				Expect(jazzLib.TotalAlbums).To(Equal(1))

				// Error should be empty (warnings don't count as scan errors)
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(BeEmpty())
			})
		})

		Context("Database errors during multi-library scanning", func() {
			BeforeEach(func() {
				// Set up content for 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 propagate database errors and stop scanning", func() {
				// Install mock repo that injects DB error
				mfRepo := &mockMediaFileRepo{
					MediaFileRepository:        ds.RealDS.MediaFile(ctx),
					GetMissingAndMatchingError: errors.New("database connection failed"),
				}
				ds.MockedMediaFile = mfRepo

				// Scan should return the database error
				Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed")))

				// Error should be recorded in scanner properties
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(ContainSubstring("database connection failed"))
			})

			It("should preserve error information in scanner properties", func() {
				// Install mock repo that injects DB error
				mfRepo := &mockMediaFileRepo{
					MediaFileRepository:        ds.RealDS.MediaFile(ctx),
					GetMissingAndMatchingError: errors.New("critical database error"),
				}
				ds.MockedMediaFile = mfRepo

				// Attempt scan (should fail)
				Expect(runScanner(ctx, false)).To(HaveOccurred())

				// Check that error is recorded in scanner properties
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(ContainSubstring("critical database error"))

				// Scan type should still be recorded
				scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
				Expect(scanType).To(BeElementOf("incremental", "quick"))
			})
		})

		Context("Mixed error scenarios", func() {
			var rockFS storagetest.FakeFS

			BeforeEach(func() {
				// Set up rock library with filesystem that can error
				rock := template(_t{"albumartist": "Metallica", "album": "Master of Puppets", "year": 1986, "genre": "Metal"})
				rockFS = createFS("rock", fstest.MapFS{
					"Metallica/Master of Puppets/01 - Battery.mp3":           rock(track(1, "Battery")),
					"Metallica/Master of Puppets/02 - Master of Puppets.mp3": rock(track(2, "Master of Puppets")),
				})

				// Set up jazz library normally
				jazz := template(_t{"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz"})
				createFS("jazz", fstest.MapFS{
					"Herbie Hancock/Head Hunters/01 - Chameleon.mp3": jazz(track(1, "Chameleon")),
				})
			})

			It("should handle filesystem errors in one library while other succeeds", func() {
				// Inject filesystem error in rock library
				rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("disk read error"))

				// Scan should complete with warnings (not hard error)
				warnings, err := s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred())
				Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error")

				// Jazz library should scan completely successfully
				jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib2.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzFiles).To(HaveLen(1))
				Expect(jazzFiles[0].Title).To(Equal("Chameleon"))

				// Jazz library statistics should be accurate
				jazzLib, err := ds.Library(ctx).Get(lib2.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzLib.TotalSongs).To(Equal(1))
				Expect(jazzLib.TotalAlbums).To(Equal(1))

				// Rock library may have partial content (depending on scanner implementation)
				rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib1.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				// No specific expectation - some files may have been imported despite errors
				_ = rockFiles

				// Error should be empty (warnings don't count as scan errors)
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(BeEmpty())
			})

			It("should handle partial failures gracefully", func() {
				// Create a scenario where rock has filesystem issues and jazz has normal content
				rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("file corruption"))

				// Do an initial scan with filesystem error
				warnings, err := s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred())
				Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption")

				// Verify that the working parts completed successfully
				jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib2.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzFiles).To(HaveLen(1))

				// Scanner properties should reflect successful completion despite warnings
				scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
				Expect(scanType).To(Equal("full"))

				// Start time should be recorded
				startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
				Expect(startTimeStr).ToNot(BeEmpty())

				// Error should be empty (warnings don't count as scan errors)
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(BeEmpty())
			})
		})

		Context("Error recovery in multi-library context", func() {
			It("should recover from previous library-specific errors", func() {
				// Set up initial content
				rock := template(_t{"albumartist": "Iron Maiden", "album": "The Number of the Beast", "year": 1982, "genre": "Metal"})
				jazz := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"})

				rockFS := createFS("rock", fstest.MapFS{
					"Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")),
				})

				createFS("jazz", fstest.MapFS{
					"John Coltrane/Giant Steps/01 - Giant Steps.mp3": jazz(track(1, "Giant Steps")),
				})

				// First scan with filesystem error in rock
				rockFS.SetError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3", errors.New("temporary disk error"))
				warnings, err := s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred()) // Should succeed with warnings
				Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")

				// Clear the error and add more content - recreate the filesystem completely
				rockFS.ClearError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3")

				// Create a new filesystem with both files
				createFS("rock", fstest.MapFS{
					"Iron Maiden/The Number of the Beast/01 - Invaders.mp3":               rock(track(1, "Invaders")),
					"Iron Maiden/The Number of the Beast/02 - Children of the Damned.mp3": rock(track(2, "Children of the Damned")),
				})

				// Second scan should recover and import all rock content
				warnings, err = s.ScanAll(ctx, true)
				Expect(err).ToNot(HaveOccurred())
				Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery")

				// Verify both libraries now have content (at least jazz should work)
				rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib1.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				// The scanner should recover and import both rock files
				Expect(len(rockFiles)).To(Equal(2))

				jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
					Filters: squirrel.Eq{"library_id": lib2.ID},
				})
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzFiles).To(HaveLen(1))

				// Both libraries should have correct content counts
				rockLib, err := ds.Library(ctx).Get(lib1.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(rockLib.TotalSongs).To(Equal(2))

				jazzLib, err := ds.Library(ctx).Get(lib2.ID)
				Expect(err).ToNot(HaveOccurred())
				Expect(jazzLib.TotalSongs).To(Equal(1))

				// Error should be empty (successful recovery)
				lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
				Expect(err).ToNot(HaveOccurred())
				Expect(lastError).To(BeEmpty())
			})
		})
	})

	Context("Scanner Properties", func() {
		It("should persist last scan type, start time and error properties", func() {
			// trivial FS setup
			rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
			_ = createFS("rock", fstest.MapFS{
				"AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
			})

			// Run a full scan
			Expect(runScanner(ctx, true)).To(Succeed())

			// Validate properties
			scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
			Expect(scanType).To(Equal("full"))

			startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
			Expect(startTimeStr).ToNot(BeEmpty())
			_, err := time.Parse(time.RFC3339, startTimeStr)
			Expect(err).ToNot(HaveOccurred())

			lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
			Expect(err).ToNot(HaveOccurred())
			Expect(lastError).To(BeEmpty())
		})
	})
})