From c67c19c302d503c015bc053d0b47c46ecb4d9f24 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 14 Sep 2026 08:51:56 -0400 Subject: [PATCH] feat(scanner): skip folders containing a .nomedia file Folders with a .nomedia file are now skipped during scans, the same way as folders with an empty .ndignore file. The file contents are ignored, and it takes precedence over any .ndignore in the same folder. .nomedia is the marker used by Kodi, Plex and Android to exclude folders from media scans, so libraries already set up for those tools work without extra files. Note: folders that only use .nomedia to hide cover art from Android gallery apps will no longer be scanned. --- consts/consts.go | 1 + scanner/README.md | 2 +- scanner/ignore_checker.go | 7 ++++++- scanner/ignore_checker_test.go | 14 ++++++++++++++ scanner/walk_dir_tree_test.go | 9 +++++++-- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index 486ea66bc..f6be9315c 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -88,6 +88,7 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" + NoMediaFile = ".nomedia" ArtworkFolder = "artwork" // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed // upload folders beside it so Prune's sweep never reaches them. diff --git a/scanner/README.md b/scanner/README.md index b2c682381..0f80ddffb 100644 --- a/scanner/README.md +++ b/scanner/README.md @@ -175,7 +175,7 @@ flowchart TD 1. **Folder Traversal** - Uses `walkDirTree` to traverse the directory structure - Handles symbolic links and hidden files - - Processes `.ndignore` files for exclusions + - Processes `.ndignore` files for exclusions, and skips folders containing a `.nomedia` file - Maps files to appropriate types (audio, image, playlist) 2. **Metadata Extraction** diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go index f0aedb079..bf50a46d1 100644 --- a/scanner/ignore_checker.go +++ b/scanner/ignore_checker.go @@ -102,8 +102,13 @@ func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool // loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns. // If the file doesn't exist, returns an empty slice. -// If the file exists but is empty, returns a pattern to ignore everything ("**/*"). +// If the file exists but is empty, or a .nomedia file exists, returns a pattern to ignore everything ("**/*"). func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string { + if _, err := fs.Stat(ic.fsys, path.Join(folder, consts.NoMediaFile)); err == nil { + log.Trace(ctx, "Scanner: .nomedia file found, ignoring everything", "path", folder) + return []string{"**/*"} + } + ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile) var patterns []string diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go index 5378ed4fa..610073275 100644 --- a/scanner/ignore_checker_test.go +++ b/scanner/ignore_checker_test.go @@ -37,6 +37,20 @@ var _ = Describe("IgnoreChecker", func() { }) }) + DescribeTable("when .nomedia file exists", + func(files fstest.MapFS) { + ic = newIgnoreChecker(files) + patterns := ic.loadPatternsFromFolder(ctx, ".") + Expect(patterns).To(Equal([]string{"**/*"})) + }, + Entry("empty", fstest.MapFS{".nomedia": &fstest.MapFile{}}), + Entry("with content", fstest.MapFS{".nomedia": &fstest.MapFile{Data: []byte("*.txt")}}), + Entry("alongside .ndignore", fstest.MapFS{ + ".nomedia": &fstest.MapFile{}, + ".ndignore": &fstest.MapFile{Data: []byte("*.txt")}, + }), + ) + DescribeTable("parsing .ndignore content", func(content string, expectedPatterns []string) { fsys := fstest.MapFS{ diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 9fb650c4d..f0829fe07 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -53,6 +53,9 @@ var _ = Describe("walk_dir_tree", func() { "root/h/.hidden.mp3": {}, "root/i/.git/config": {}, "root/i/.streams/stream.mp3": {}, + "root/j/.nomedia": {}, + "root/j/f1.mp3": {}, + "root/j/sub/f2.mp3": {}, }, } job = &scanJob{ @@ -100,6 +103,8 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/c"].audioFiles).To(BeEmpty()) Expect(folders["root/c"].imageFiles).To(BeEmpty()) Expect(folders).ToNot(HaveKey("root/d")) + Expect(folders["root/j"].audioFiles).To(BeEmpty()) + Expect(folders).ToNot(HaveKey("root/j/sub")) // By default (Scanner.IgnoreDotFolders == true), dot-prefixed // folders are skipped, dot-prefixed files are not indexed, and @@ -122,8 +127,8 @@ var _ = Describe("walk_dir_tree", func() { Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) } }, - Entry("with symlinks enabled", true, 11), - Entry("with symlinks disabled", false, 10), + Entry("with symlinks enabled", true, 12), + Entry("with symlinks disabled", false, 11), ) DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled",