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.
This commit is contained in:
Deluan 2026-09-14 08:51:56 -04:00
commit c67c19c302
5 changed files with 29 additions and 4 deletions

View file

@ -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.

View file

@ -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**

View file

@ -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

View file

@ -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{

View file

@ -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",