navidrome/scanner/walk_dir_tree.go

183 lines
5.2 KiB
Go

package scanner
import (
"context"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type (
dirStats struct {
Path string
ModTime time.Time
Images []string
ImagesUpdatedAt time.Time
HasPlaylist bool
AudioFilesCount uint32
}
walkResults = chan dirStats
)
func walkDirTree(ctx context.Context, rootFolder string, results walkResults) error {
err := walkFolder(ctx, rootFolder, rootFolder, results)
if err != nil {
log.Error(ctx, "Error loading directory tree", err)
}
close(results)
return err
}
func walkFolder(ctx context.Context, rootPath string, currentFolder string, results walkResults) error {
children, stats, err := loadDir(ctx, currentFolder)
if err != nil {
return err
}
for _, c := range children {
err := walkFolder(ctx, rootPath, c, results)
if err != nil {
return err
}
}
dir := filepath.Clean(currentFolder)
log.Trace(ctx, "Found directory", "dir", dir, "audioCount", stats.AudioFilesCount,
"images", stats.Images, "hasPlaylist", stats.HasPlaylist)
stats.Path = dir
results <- *stats
return nil
}
func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
var children []string
stats := &dirStats{}
dirInfo, err := os.Stat(dirPath)
if err != nil {
log.Error(ctx, "Error stating dir", "path", dirPath, err)
return nil, nil, err
}
stats.ModTime = dirInfo.ModTime()
dir, err := os.Open(dirPath)
if err != nil {
log.Error(ctx, "Error in Opening directory", "path", dirPath, err)
return children, stats, err
}
defer dir.Close()
dirEntries := fullReadDir(ctx, dir)
for _, entry := range dirEntries {
isDir, err := isDirOrSymlinkToDir(dirPath, entry)
// Skip invalid symlinks
if err != nil {
log.Error(ctx, "Invalid symlink", "dir", filepath.Join(dirPath, entry.Name()), err)
continue
}
if isDir && !isDirIgnored(dirPath, entry) && isDirReadable(dirPath, entry) {
children = append(children, filepath.Join(dirPath, entry.Name()))
} else {
fileInfo, err := entry.Info()
if err != nil {
log.Error(ctx, "Error getting fileInfo", "name", entry.Name(), err)
return children, stats, err
}
if fileInfo.ModTime().After(stats.ModTime) {
stats.ModTime = fileInfo.ModTime()
}
switch {
case model.IsAudioFile(entry.Name()):
stats.AudioFilesCount++
case model.IsValidPlaylist(entry.Name()):
stats.HasPlaylist = true
case model.IsImageFile(entry.Name()):
stats.Images = append(stats.Images, entry.Name())
if fileInfo.ModTime().After(stats.ImagesUpdatedAt) {
stats.ImagesUpdatedAt = fileInfo.ModTime()
}
}
}
}
return children, stats, 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 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) []os.DirEntry {
var allDirs []os.DirEntry
var prevErrStr = ""
for {
dirs, err := dir.ReadDir(-1)
allDirs = append(allDirs, dirs...)
if err == nil {
break
}
log.Warn(ctx, "Skipping DirEntry", err)
if prevErrStr == err.Error() {
log.Error(ctx, "Duplicate DirEntry failure, bailing", err)
break
}
prevErrStr = err.Error()
}
sort.Slice(allDirs, func(i, j int) bool { return allDirs[i].Name() < allDirs[j].Name() })
return allDirs
}
// 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(baseDir string, dirEnt fs.DirEntry) (bool, error) {
if dirEnt.IsDir() {
return true, nil
}
if dirEnt.Type()&os.ModeSymlink == 0 {
return false, nil
}
// Does this symlink point to a directory?
fileInfo, err := os.Stat(filepath.Join(baseDir, dirEnt.Name()))
if err != nil {
return false, err
}
return fileInfo.IsDir(), nil
}
// isDirIgnored returns true if the directory represented by dirEnt contains an
// `ignore` file (named after consts.SkipScanFile)
func isDirIgnored(baseDir string, dirEnt fs.DirEntry) bool {
// allows Album folders for albums which e.g. start with ellipses
name := dirEnt.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") {
return true
}
if runtime.GOOS == "windows" && strings.EqualFold(name, "$RECYCLE.BIN") {
return true
}
_, err := os.Stat(filepath.Join(baseDir, name, consts.SkipScanFile))
return err == nil
}
// isDirReadable returns true if the directory represented by dirEnt is readable
func isDirReadable(baseDir string, dirEnt fs.DirEntry) bool {
path := filepath.Join(baseDir, dirEnt.Name())
res, err := utils.IsDirReadable(path)
if !res {
log.Warn("Skipping unreadable directory", "path", path, err)
}
return res
}