From 55986a4daa9d94acb671e97ca7a2c4abe5ee0195 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 7 Oct 2026 10:14:32 -0400 Subject: [PATCH] fix(scanner): let quick scans update num_playlists when PlaylistsPath changes (#6276) A quick scan only rewrites a folder when its hash changes, and the hash did not depend on PlaylistsPath. Folders indexed while their playlists were ignored (the Windows bug in #6276, or an older PlaylistsPath) kept num_playlists = 0 when a cover image or a subfolder kept their row, so their playlists were never imported without a full scan. The hash now includes whether PlaylistsPath includes the folder, only for folders with playlist files, so other folders keep their hash. Each folder with playlists is rescanned once after upgrading. Tests scan real folders: recovery on an unchanged quick scan (playlist only, cover image, subfolder), full scan, touched playlist, a folder that stays excluded, one that becomes excluded, and a folder without playlists that is not rescanned. --- scanner/folder_entry.go | 4 + scanner/folder_entry_test.go | 18 +++++ scanner/scanner_playlists_path_test.go | 103 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index e7eef223c..a3f51790c 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -113,6 +113,10 @@ func (f *folderEntry) hash() string { f.numSubFolders, f.imagesUpdatedAt.UTC(), ) + // Lets a quick scan update num_playlists when PlaylistsPath starts or stops including the folder + if len(f.playlistFiles) > 0 { + _, _ = fmt.Fprintf(h, ":%t", playlists.InPath(*model.NewFolder(f.job.lib, f.path))) + } // Sort the keys of audio, image and playlist files to ensure consistent hashing audioKeys := slices.Collect(maps.Keys(f.audioFiles)) diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index 4493f9309..9590430cd 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -231,6 +231,24 @@ var _ = Describe("folder_entry", func() { Expect(hash1).To(Equal(hash2)) }) + It("produces different hash when PlaylistsPath starts including the folder's playlists", func() { + entry.playlistFiles = map[string]fs.DirEntry{"list.nsp": &fakeDirEntry{name: "list.nsp"}} + conf.Server.PlaylistsPath = "other" + excluded := entry.hash() + + conf.Server.PlaylistsPath = "test/folder" + Expect(entry.hash()).ToNot(Equal(excluded)) + }) + + It("keeps the hash of a folder without playlists when PlaylistsPath changes", func() { + entry.audioFiles = map[string]fs.DirEntry{"song.mp3": &fakeDirEntry{name: "song.mp3"}} + conf.Server.PlaylistsPath = "other" + excluded := entry.hash() + + conf.Server.PlaylistsPath = "test/folder" + Expect(entry.hash()).To(Equal(excluded)) + }) + It("produces different hash when audio files change", func() { entry.audioFiles = map[string]fs.DirEntry{ "song1.mp3": &fakeDirEntry{name: "song1.mp3"}, diff --git a/scanner/scanner_playlists_path_test.go b/scanner/scanner_playlists_path_test.go index 0985f4654..5524ca2de 100644 --- a/scanner/scanner_playlists_path_test.go +++ b/scanner/scanner_playlists_path_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "runtime" "slices" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -177,4 +178,106 @@ var _ = Describe("Scanner - PlaylistsPath", Ordered, ContinueOnFailure, func() { Entry("brackets: escaped braces", `\{Mix\}`, []string{"{Mix}"}, []string{"Braces"}), ) }) + + // Folders indexed while their playlists were ignored keep num_playlists = 0. GC purges a folder + // left with nothing else, but a cover image or a subfolder keeps its row. + Describe("recovering folders indexed while their playlists were ignored", func() { + indexIgnoringPlaylists := func() map[string]model.Folder { + GinkgoHelper() + conf.Server.PlaylistsPath = "Music/**" + scan(true) + expectResults(nil, nil) + folders, err := ds.Folder().GetAll(ctx) + Expect(err).ToNot(HaveOccurred()) + stored := map[string]model.Folder{} + for _, f := range folders { + Expect(f.NumPlaylists).To(BeZero()) + stored[path.Join(f.Path, f.Name)] = f + } + return stored + } + + It("recovery: playlist-only folder, unchanged quick scan", func() { + writeNSP("Playlists/navidrome/Rock.nsp", "Rock") + Expect(indexIgnoringPlaylists()).ToNot(HaveKey("Playlists/navidrome"), "GC purged the folder") + + conf.Server.PlaylistsPath = "Playlists/navidrome" + scan(false) + expectResults([]string{"Playlists/navidrome"}, []string{"Rock"}) + }) + + Describe("folder kept by a playlist cover image", func() { + var stored map[string]model.Folder + + BeforeEach(func() { + writeNSP("Playlists/navidrome/Rock.nsp", "Rock") + Expect(os.WriteFile(filepath.Join(libPath, "Playlists", "navidrome", "Rock.jpg"), []byte("synthetic"), 0600)).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(libPath, "Art"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libPath, "Art", "cover.jpg"), []byte("synthetic"), 0600)).To(Succeed()) + stored = indexIgnoringPlaylists() + Expect(stored).To(HaveKey("Playlists/navidrome")) + Expect(stored["Playlists/navidrome"].Path).To(Equal("Playlists")) + Expect(stored["Playlists/navidrome"].ImageFiles).To(ConsistOf("Rock.jpg")) + Expect(stored).To(HaveKey("Art")) + }) + + It("recovery: cover image folder, unchanged quick scan", func() { + conf.Server.PlaylistsPath = "Playlists/navidrome" + scan(false) + expectResults([]string{"Playlists/navidrome"}, []string{"Rock"}) + }) + + It("recovery: cover image folder, full scan", func() { + conf.Server.PlaylistsPath = "Playlists/navidrome" + scan(true) + expectResults([]string{"Playlists/navidrome"}, []string{"Rock"}) + }) + + It("recovery: cover image folder, quick scan after touching the playlist", func() { + conf.Server.PlaylistsPath = "Playlists/navidrome" + later := time.Now().Add(time.Minute) + Expect(os.Chtimes(filepath.Join(libPath, "Playlists", "navidrome", "Rock.nsp"), later, later)).To(Succeed()) + scan(false) + expectResults([]string{"Playlists/navidrome"}, []string{"Rock"}) + }) + + It("recovery: still-excluded folder stays ignored on a quick scan", func() { + conf.Server.PlaylistsPath = "Other/**" + scan(false) + expectResults(nil, nil) + }) + + It("recovery: folder without playlist files is not rescanned", func() { + conf.Server.PlaylistsPath = "Playlists/navidrome" + scan(false) + art, err := ds.Folder().Get(ctx, stored["Art"].ID) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Hash).To(Equal(stored["Art"].Hash)) + Expect(art.UpdateAt).To(BeTemporally("==", stored["Art"].UpdateAt)) + }) + + It("recovery: quick scan stops counting playlists PlaylistsPath no longer includes", func() { + conf.Server.PlaylistsPath = "Playlists/navidrome" + scan(false) + expectResults([]string{"Playlists/navidrome"}, []string{"Rock"}) + + conf.Server.PlaylistsPath = "Other/**" + scan(false) + // Already imported playlists stay; only the folder count changes + expectResults(nil, []string{"Rock"}) + }) + }) + + It("recovery: parent folder with a subfolder, unchanged quick scan", func() { + writeNSP("Playlists/navidrome/Rock.nsp", "Rock") + writeNSP("Playlists/navidrome/Deep/Nested.nsp", "Nested") + stored := indexIgnoringPlaylists() + Expect(stored).To(HaveKey("Playlists/navidrome"), "kept as the parent of Deep") + Expect(stored).ToNot(HaveKey("Playlists/navidrome/Deep"), "GC purged the leaf") + + conf.Server.PlaylistsPath = "Playlists/navidrome/**" + scan(false) + expectResults([]string{"Playlists/navidrome", "Playlists/navidrome/Deep"}, []string{"Rock", "Nested"}) + }) + }) })