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