package scanner
import (
"context"
"io/fs"
"maps"
"path"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) {
results := make(chan *folderEntry)
folders := targetFolders
if len(targetFolders) == 0 {
folders = []string{"."}
}
go func() {
defer close(results)
for _, folderPath := range folders {
if utils.IsCtxDone(ctx) {
return
}
_, err := fs.Stat(job.fs, folderPath)
if err != nil {
log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err)
continue
}
if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
continue
}
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
}
err = walkFolder(ctx, job, folderPath, checker, results)
if utils.IsCtxDone(ctx) {
return
}
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 {
_ = checker.Push(ctx, currentFolder)
defer checker.Pop()
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", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt,
"updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children))
folder.path = dir
folder.elapsed.Start()
select {
case results <- folder:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
dirInfo, err := fs.Stat(job.fs, dirPath)
if err != nil {
log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err)
return nil, nil, err
}
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)
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.playlistFiles[entry.Name()] = entry
case model.IsImageFile(name):
folder.imageFiles[entry.Name()] = entry
folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
}
}
}
return folder, children, nil
}
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
}
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 !conf.Server.Scanner.FollowSymlinks {
return false, nil
}
fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name()))
if err != nil {
return false, err
}
return fileInfo.IsDir(), nil
}
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
info, err := fs.Lstat(fsys, p)
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
return true
}
}
return false
}
const maxSymlinkHops = 40
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
}
if resolver, ok := fsys.(storage.SymlinkResolverFS); ok {
target, err := resolver.ResolveSymlink(linkPath)
if err != nil {
log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err)
return "", false
}
resolved := filepath.Base(target)
log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", target, "name", resolved)
return resolved, true
}
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) {
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
}
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
}
var ignoredDirs = []string{
"$RECYCLE.BIN",
"#snapshot",
"@Recycle",
"@eaDir",
"@Recently-Snapshot",
".git",
".streams",
"lost+found",
}
func isIgnoredEntry(name string, isDir bool) bool {
if isDir && isDirIgnored(name) {
return true
}
return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders)
}
func isDirIgnored(name string) bool {
return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) })
}
func isDotEntry(name string) bool {
return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}