mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* 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
311 lines
11 KiB
Go
311 lines
11 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"maps"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils"
|
|
)
|
|
|
|
// walkDirTree recursively walks the directory tree starting from the given targetFolders.
|
|
// If no targetFolders are provided, it starts from the root folder (".").
|
|
// It returns a channel of folderEntry pointers representing each folder found.
|
|
func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) {
|
|
results := make(chan *folderEntry)
|
|
folders := targetFolders
|
|
if len(targetFolders) == 0 {
|
|
// No specific folders provided, scan the root folder
|
|
folders = []string{"."}
|
|
}
|
|
go func() {
|
|
defer close(results)
|
|
for _, folderPath := range folders {
|
|
if utils.IsCtxDone(ctx) {
|
|
return
|
|
}
|
|
|
|
// Check if target folder exists before walking it
|
|
// If it doesn't exist (e.g., deleted between watcher detection and scan execution),
|
|
// skip it so it remains in job.lastUpdates and gets handled in following steps
|
|
_, err := fs.Stat(job.fs, folderPath)
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err)
|
|
continue
|
|
}
|
|
|
|
// Create checker and push patterns from root to this folder
|
|
checker := newIgnoreChecker(job.fs)
|
|
err = checker.PushAllParents(ctx, folderPath)
|
|
if err != nil {
|
|
log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err)
|
|
continue
|
|
}
|
|
|
|
// Recursively walk this folder and all its children
|
|
err = walkFolder(ctx, job, folderPath, checker, results)
|
|
if err != nil {
|
|
log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err)
|
|
continue
|
|
}
|
|
}
|
|
log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
|
|
}()
|
|
return results, nil
|
|
}
|
|
|
|
func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error {
|
|
// Push patterns for this folder onto the stack
|
|
_ = checker.Push(ctx, currentFolder)
|
|
defer checker.Pop() // Pop patterns when leaving this folder
|
|
|
|
folder, children, err := loadDir(ctx, job, currentFolder, checker)
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
|
|
return nil
|
|
}
|
|
for _, c := range children {
|
|
err := walkFolder(ctx, job, c, checker, results)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
dir := path.Clean(currentFolder)
|
|
log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles),
|
|
"images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt,
|
|
"updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children))
|
|
folder.path = dir
|
|
folder.elapsed.Start()
|
|
|
|
results <- folder
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
|
|
// Check if directory exists before creating the folder entry
|
|
// This is important to avoid removing the folder from lastUpdates if it doesn't exist
|
|
dirInfo, err := fs.Stat(job.fs, dirPath)
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err)
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Now that we know the folder exists, create the entry (which removes it from lastUpdates)
|
|
folder = job.createFolderEntry(dirPath)
|
|
folder.modTime = dirInfo.ModTime()
|
|
|
|
dir, err := job.fs.Open(dirPath)
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Error in Opening directory", "path", dirPath, err)
|
|
return folder, children, err
|
|
}
|
|
defer dir.Close()
|
|
dirFile, ok := dir.(fs.ReadDirFile)
|
|
if !ok {
|
|
log.Error(ctx, "Not a directory", "path", dirPath)
|
|
return folder, children, err
|
|
}
|
|
|
|
entries := fullReadDir(ctx, dirFile)
|
|
children = make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
entryPath := path.Join(dirPath, entry.Name())
|
|
if checker.ShouldIgnore(ctx, entryPath) {
|
|
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
|
|
continue
|
|
}
|
|
if ctx.Err() != nil {
|
|
return folder, children, ctx.Err()
|
|
}
|
|
isDir, err := isDirOrSymlinkToDir(job.fs, dirPath, entry)
|
|
// Skip invalid symlinks
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err)
|
|
continue
|
|
}
|
|
if isIgnoredEntry(entry.Name(), isDir) {
|
|
continue
|
|
}
|
|
if isDir && isDirReadable(ctx, job.fs, entryPath) {
|
|
children = append(children, entryPath)
|
|
folder.numSubFolders++
|
|
} else {
|
|
fileInfo, err := entry.Info()
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Error getting fileInfo", "name", entry.Name(), err)
|
|
return folder, children, err
|
|
}
|
|
if fileInfo.ModTime().After(folder.modTime) {
|
|
folder.modTime = fileInfo.ModTime()
|
|
}
|
|
name, ok := resolveEntryName(ctx, job.fs, dirPath, entry)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch {
|
|
case model.IsAudioFile(name):
|
|
folder.audioFiles[entry.Name()] = entry
|
|
case model.IsValidPlaylist(name):
|
|
folder.numPlaylists++
|
|
case model.IsImageFile(name):
|
|
folder.imageFiles[entry.Name()] = entry
|
|
folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
|
|
}
|
|
}
|
|
}
|
|
return folder, children, nil
|
|
}
|
|
|
|
// fullReadDir reads all files in the folder, skipping the ones with errors.
|
|
// It also detects when it is "stuck" with an error in the same directory over and over.
|
|
// In this case, it stops and returns whatever it was able to read until it got stuck.
|
|
// See discussion here: https://github.com/navidrome/navidrome/issues/1164#issuecomment-881922850
|
|
func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []fs.DirEntry {
|
|
var allEntries []fs.DirEntry
|
|
var prevErrStr = ""
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
entries, err := dir.ReadDir(-1)
|
|
allEntries = append(allEntries, entries...)
|
|
if err == nil {
|
|
break
|
|
}
|
|
log.Warn(ctx, "Skipping DirEntry", err)
|
|
if prevErrStr == err.Error() {
|
|
log.Error(ctx, "Scanner: Duplicate DirEntry failure, bailing", err)
|
|
break
|
|
}
|
|
prevErrStr = err.Error()
|
|
}
|
|
sort.Slice(allEntries, func(i, j int) bool { return allEntries[i].Name() < allEntries[j].Name() })
|
|
return allEntries
|
|
}
|
|
|
|
// isDirOrSymlinkToDir returns true if and only if the dirEnt represents a file
|
|
// system directory, or a symbolic link to a directory. Note that if the dirEnt
|
|
// is not a directory but is a symbolic link, this method will resolve by
|
|
// sending a request to the operating system to follow the symbolic link.
|
|
// originally copied from github.com/karrick/godirwalk, modified to use dirEntry for
|
|
// efficiency for go 1.16 and beyond
|
|
func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, error) {
|
|
if dirEnt.IsDir() {
|
|
return true, nil
|
|
}
|
|
if dirEnt.Type()&fs.ModeSymlink == 0 {
|
|
return false, nil
|
|
}
|
|
// If symlinks are disabled, return false for symlinks
|
|
if !conf.Server.Scanner.FollowSymlinks {
|
|
return false, nil
|
|
}
|
|
// Does this symlink point to a directory?
|
|
fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name()))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return fileInfo.IsDir(), nil
|
|
}
|
|
|
|
const maxSymlinkHops = 40
|
|
|
|
// resolveEntryName returns the name to classify the entry by, and whether to
|
|
// consider it at all. Symlinks are resolved to their final target so the caller
|
|
// classifies by the target's extension, not the link's name. Returns ok=false
|
|
// when symlinks are disabled or the target can't be resolved.
|
|
func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) {
|
|
if entry.Type()&fs.ModeSymlink == 0 {
|
|
return entry.Name(), true
|
|
}
|
|
linkPath := path.Join(dirPath, entry.Name())
|
|
if !conf.Server.Scanner.FollowSymlinks {
|
|
log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath)
|
|
return "", false
|
|
}
|
|
cur := linkPath
|
|
for hop := 0; hop < maxSymlinkHops; hop++ {
|
|
target, err := fs.ReadLink(fsys, cur)
|
|
if err != nil {
|
|
if hop == 0 {
|
|
log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err)
|
|
return "", false
|
|
}
|
|
resolved := path.Base(cur)
|
|
log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved)
|
|
return resolved, true
|
|
}
|
|
if path.IsAbs(target) {
|
|
// Absolute targets are not valid fs.FS paths, so the next ReadLink fails and
|
|
// resolution stops here, leaving cur as the target to classify by name.
|
|
cur = target
|
|
} else {
|
|
cur = path.Join(path.Dir(cur), target)
|
|
}
|
|
}
|
|
log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath)
|
|
return "", false
|
|
}
|
|
|
|
// isDirReadable returns true if the directory represented by dirEnt is readable
|
|
func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
|
|
dir, err := fsys.Open(dirPath)
|
|
if err != nil {
|
|
log.Warn("Scanner: Skipping unreadable directory", "path", dirPath, err)
|
|
return false
|
|
}
|
|
err = dir.Close()
|
|
if err != nil {
|
|
log.Warn(ctx, "Scanner: Error closing directory", "path", dirPath, err)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// List of special directories to ignore
|
|
var ignoredDirs = []string{
|
|
"$RECYCLE.BIN",
|
|
"#snapshot",
|
|
"@Recycle",
|
|
"@Recently-Snapshot",
|
|
".git",
|
|
".streams",
|
|
"lost+found",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders)
|
|
}
|
|
|
|
// 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, "..")
|
|
}
|