feat(scanner): add Scanner.IgnoreDotFolders to allow indexing dot-prefixed folders (#5568)
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-1 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-2 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-3 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-4 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-5 (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-6 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-7 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-8 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-9 (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-10 (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled

* feat(scanner): add Scanner.IgnoreDotFolders to allow scanning dot folders

Adds a new Scanner.IgnoreDotFolders option (default true, preserving current
behavior) that, when disabled, lets the scanner traverse folders whose names
start with a dot, such as albums like ".Hack Sign Original Soundtrack".

Previously every dot-prefixed entry was skipped unconditionally before the
directory check, so such album folders were never indexed. The walk loop now
determines whether an entry is a directory first, then skips dot-prefixed files
always and dot-prefixed folders only when IgnoreDotFolders is enabled. Special
system directories are still ignored in all cases via the ignoredDirs blocklist,
which now also lists .git explicitly (it was previously caught only by the
generic dot-prefix rule). isDirIgnored is reduced to a pure blocklist check and
the name-only predicate is renamed from isEntryIgnored to isDotEntry.

* refactor(scanner): centralize entry ignore policy in isIgnoredEntry

Consolidates the directory-entry ignore decision into a single isIgnoredEntry
helper so the walk loop reads as pure dispatch (recurse into directories,
classify files) instead of interleaving ignore policy with traversal.

The dot-prefix rule and the ignoredDirs blocklist were previously checked in two
separate places inside loadDir's loop. They are now combined behind one helper
that takes the entry name and whether it is a directory. isDirIgnored remains a
standalone blocklist predicate because the file watcher (isIgnoredPath) calls it
directly. Adds focused unit tests for isIgnoredEntry covering both states of
Scanner.IgnoreDotFolders. No behavior change.

* fix(scanner): stop watcher from scanning ignored dot folders

A filesystem change inside a dot-prefixed folder (e.g. ".Hidden Album/track.mp3")
previously triggered a targeted scan of that folder, because isIgnoredPath let
all media files through and only checked the changed path's parent against the
ignore list (which never matched for nested paths due to the trailing separator).
With Scanner.IgnoreDotFolders enabled this caused the folder to be indexed even
though a full scan would skip it.

The watcher now ignores any change located inside an ignored directory via a new
isUnderIgnoredDir helper that reuses the same isIgnoredEntry policy as the scan
walk, and checks the entry itself with isIgnoredEntry instead of the parent dir.
This keeps the watcher and the scanner in agreement for both dot-folders (gated
by the flag) and the ignoredDirs blocklist. Adds direct table tests for
isIgnoredPath covering both states of the option.

* fix(scanner): exclude '.' from isDotEntry and ignore dot media files in watcher

Addresses code review feedback:

- isDotEntry now excludes the literal "." reference, matching its documentation.
  Previously isDotEntry(".") returned true, which could mark a path component as
  a dot-entry in the watcher.
- isIgnoredPath now ignores dot-prefixed media files (e.g. ".hidden.mp3") so the
  watcher matches the scanner, which always skips dot files. Non-media leaves
  still fall through to the directory-assumption check, so dot-folders continue
  to follow Scanner.IgnoreDotFolders.

Adds unit tests for isDotEntry and watcher coverage for dot-prefixed media files.

* docs(scanner): clarify isDotEntry multi-dot exclusion and add test

Expand the isDotEntry comment to explain why names with two or more
leading dots (".."/"..foo"/"...Album") are not treated as hidden, which
surprised a reviewer testing dot-folder scanning. Add a "..foo" test case
to make the two-leading-dots behavior explicit.

Claude-Session: https://claude.ai/code/session_012STiDTyhZAdH8JNtdNe8L1
This commit is contained in:
Deluan Quintão 2026-06-19 19:13:16 -04:00 committed by GitHub
parent 6f7af6650c
commit 05105e91d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 187 additions and 27 deletions

View file

@ -162,6 +162,7 @@ type scannerOptions struct {
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
PurgeMissing string // Values: "never", "always", "full"
}
@ -821,6 +822,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.ignoredotfolders", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true)

View file

@ -123,9 +123,6 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue
}
if isEntryIgnored(entry.Name()) {
continue
}
if ctx.Err() != nil {
return folder, children, ctx.Err()
}
@ -135,7 +132,10 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err)
continue
}
if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) {
if isIgnoredEntry(entry.Name(), isDir) {
continue
}
if isDir && isDirReadable(ctx, job.fs, entryPath) {
children = append(children, entryPath)
folder.numSubFolders++
} else {
@ -276,22 +276,35 @@ var ignoredDirs = []string{
"#snapshot",
"@Recycle",
"@Recently-Snapshot",
".git",
".streams",
"lost+found",
}
// isDirIgnored returns true if the directory represented by dirEnt should be ignored
func isDirIgnored(name string) bool {
// allows Album folders for albums which eg start with ellipses
if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") {
// isIgnoredEntry returns true if a directory entry with the given name should be
// skipped during scanning. It centralizes all name- and type-based ignore policy:
// - special system directories in ignoredDirs are always ignored;
// - dot-prefixed files are always ignored;
// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled,
// allowing albums like ".Hack Sign" to be scanned when the option is off.
func isIgnoredEntry(name string, isDir bool) bool {
if isDir && isDirIgnored(name) {
return true
}
if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) {
return true
}
return false
return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders)
}
func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
// isDirIgnored returns true if the directory name is in the explicit ignoredDirs
// blocklist. Used both while walking the tree and by the file watcher.
func isDirIgnored(name string) bool {
return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) })
}
// isDotEntry returns true only for names with exactly one leading dot (the
// convention for hidden entries), e.g. ".hidden". Names with two or more leading
// dots are not considered hidden: "." and ".." are the special self/parent
// references, and anything like "..foo" or "...Album" is a regular name (album
// folders sometimes start with ellipses), so all of these return false.
func isDotEntry(name string) bool {
return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}

View file

@ -49,6 +49,10 @@ var _ = Describe("walk_dir_tree", func() {
"root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")},
"root/f/secret": {Data: []byte("TOPSECRET")},
"root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")},
"root/g/.Hack Sign Original Soundtrack/track.mp3": {},
"root/h/.hidden.mp3": {},
"root/i/.git/config": {},
"root/i/.streams/stream.mp3": {},
},
}
job = &scanJob{
@ -97,6 +101,14 @@ var _ = Describe("walk_dir_tree", func() {
Expect(folders["root/c"].imageFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/d"))
// By default (Scanner.IgnoreDotFolders == true), dot-prefixed
// folders are skipped, dot-prefixed files are not indexed, and
// the special ignoredDirs (.git, .streams) are never traversed.
Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack"))
Expect(folders["root/h"].audioFiles).To(BeEmpty())
Expect(folders).ToNot(HaveKey("root/i/.git"))
Expect(folders).ToNot(HaveKey("root/i/.streams"))
// Symlink specific checks
if followSymlinks {
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
@ -110,8 +122,31 @@ var _ = Describe("walk_dir_tree", func() {
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
}
},
Entry("with symlinks enabled", true, 8),
Entry("with symlinks disabled", false, 7),
Entry("with symlinks enabled", true, 11),
Entry("with symlinks disabled", false, 10),
)
DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled",
func(followSymlinks bool) {
conf.Server.Scanner.FollowSymlinks = followSymlinks
conf.Server.Scanner.IgnoreDotFolders = false
folders := getFolders()
// Dot-prefixed album folders are now traversed and indexed
Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll(
HaveLen(1),
HaveKey("track.mp3"),
))
// Dot-prefixed files are still ignored, even with the flag off
Expect(folders["root/h"].audioFiles).To(BeEmpty())
// Special ignoredDirs remain blocked regardless of the flag
Expect(folders).ToNot(HaveKey("root/i/.git"))
Expect(folders).ToNot(HaveKey("root/i/.streams"))
},
Entry("with symlinks enabled", true),
Entry("with symlinks disabled", false),
)
})
@ -450,13 +485,61 @@ var _ = Describe("walk_dir_tree", func() {
Expect(isDirIgnored(dirName)).To(Equal(expected))
},
Entry("normal dir", "empty_folder", false),
Entry("hidden dir", ".hidden_folder", true),
Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false),
Entry("git dir", ".git", true),
Entry("streams dir", ".streams", true),
Entry("dir starting with ellipsis", "...unhidden_folder", false),
Entry("recycle bin", "$Recycle.Bin", true),
Entry("snapshot dir", "#snapshot", true),
)
})
Describe("isIgnoredEntry", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
DescribeTable("with IgnoreDotFolders enabled (default)",
func(name string, isDir, expected bool) {
conf.Server.Scanner.IgnoreDotFolders = true
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
},
Entry("normal dir", "Album", true, false),
Entry("normal file", "track.mp3", false, false),
Entry("dot folder", ".Hack Sign Original Soundtrack", true, true),
Entry("dot file", ".hidden.mp3", false, true),
Entry("blocklisted dir", ".git", true, true),
Entry("ellipsis dir", "...unhidden", true, false),
)
DescribeTable("with IgnoreDotFolders disabled",
func(name string, isDir, expected bool) {
conf.Server.Scanner.IgnoreDotFolders = false
Expect(isIgnoredEntry(name, isDir)).To(Equal(expected))
},
Entry("normal dir", "Album", true, false),
Entry("normal file", "track.mp3", false, false),
Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false),
Entry("dot file is still ignored", ".hidden.mp3", false, true),
Entry("blocklisted dir still ignored", ".git", true, true),
)
})
Describe("isDotEntry", func() {
DescribeTable("returns expected result",
func(name string, expected bool) {
Expect(isDotEntry(name)).To(Equal(expected))
},
Entry("dot folder", ".Hidden", true),
Entry("dot file", ".hidden.mp3", true),
Entry("current dir", ".", false),
Entry("parent dir", "..", false),
Entry("two leading dots", "..foo", false),
Entry("ellipsis", "...unhidden", false),
Entry("normal name", "Album", false),
)
})
Describe("fullReadDir", func() {
var (
fsys fakeFS

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"path/filepath"
"strings"
"sync"
"time"
@ -320,18 +321,35 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music
}
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
baseDir, name := filepath.Split(path)
_, name := filepath.Split(path)
// A change anywhere inside an ignored directory (a dot-folder when
// Scanner.IgnoreDotFolders is enabled, or a special system folder) must not
// trigger a scan, even for media files: the scan would skip it anyway.
if isUnderIgnoredDir(path) {
return true
}
switch {
case model.IsAudioFile(path):
return false
case model.IsValidPlaylist(path):
return false
case model.IsImageFile(path):
return false
case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path):
// A media file is normally not ignored, but a dot-prefixed one (e.g.
// ".hidden.mp3") is always skipped by the scanner, so don't scan for it.
return isDotEntry(name)
case name == ".DS_Store":
return true
}
// As it can be a deletion and not a change, we cannot reliably know if the path is a file or directory.
// But at this point, we can assume it's a directory. If it's a file, it would be ignored anyway
return isDirIgnored(baseDir)
// As it can be a deletion and not a change, we cannot reliably know if the
// path is a file or directory. But at this point, we can assume it's a
// directory. If it's a file, it would be ignored anyway.
return isIgnoredEntry(name, true)
}
// isUnderIgnoredDir returns true if any parent directory component of the given
// path is an ignored directory, reusing the same policy as the scanner walk.
func isUnderIgnoredDir(path string) bool {
dir, _ := filepath.Split(path)
for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") {
if part != "" && isIgnoredEntry(part, true) {
return true
}
}
return false
}

View file

@ -428,6 +428,50 @@ var _ = Describe("Watcher", func() {
Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
})
})
})
})
var _ = Describe("isIgnoredPath", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Context("with IgnoreDotFolders enabled (default)", func() {
BeforeEach(func() {
conf.Server.Scanner.IgnoreDotFolders = true
})
DescribeTable("returns expected result",
func(p string, expected bool) {
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
},
Entry("media file in normal folder", "rock/Album/track.mp3", false),
Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true),
Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true),
Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true),
Entry("media file inside .git", "rock/.git/track.mp3", true),
Entry("dot-folder itself", "rock/.Hidden Album", true),
Entry("normal folder itself", "rock/Album", false),
Entry(".DS_Store file", "rock/Album/.DS_Store", true),
)
})
Context("with IgnoreDotFolders disabled", func() {
BeforeEach(func() {
conf.Server.Scanner.IgnoreDotFolders = false
})
DescribeTable("returns expected result",
func(p string, expected bool) {
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
},
Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false),
Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true),
Entry("dot-folder itself is allowed", "rock/.Hidden Album", false),
Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true),
Entry(".git still ignored", "rock/.git/config", true),
)
})
})