Compare commits

...

3 commits

Author SHA1 Message Date
Deluan Quintão
b33b351dee
Merge branch 'master' into nomedia-ignore 2026-09-19 14:23:24 -04:00
Deluan
3be40677fa refactor(scanner): share the ignore-all pattern constant
Use a single ignoreAllPattern constant for both the .nomedia and the empty
.ndignore cases, so the two can't drift apart.
2026-09-14 08:55:45 -04:00
Deluan
c67c19c302 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.
2026-09-14 08:51:56 -04:00
5 changed files with 32 additions and 5 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

@ -12,6 +12,8 @@ import (
ignore "github.com/sabhiram/go-gitignore"
)
const ignoreAllPattern = "**/*"
// IgnoreChecker manages .ndignore patterns using a stack-based approach.
// Use Push() to add patterns when entering a folder, Pop() when leaving,
// and ShouldIgnore() to check if a path should be ignored.
@ -102,8 +104,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{ignoreAllPattern}
}
ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
var patterns []string
@ -138,7 +145,7 @@ func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder stri
// If the .ndignore file is empty, ignore everything
if len(patterns) == 0 {
log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
patterns = []string{"**/*"}
patterns = []string{ignoreAllPattern}
}
return patterns

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