Improve scanner (#1054)

* Handle subdirectories without rx permission correctly
Allow ogg files w/o metadata, having taglib behave more like ffmpeg

* Fix test for walk_dir_tree, fix full reading of files in permission-
constrained directories, allow directories with leading ellipses

* Sorted directory traversal is preferred, and cleanup tests

* Small refactoring to clean-up `loadDir` function and to remove some "warnings" from IntelliJ

Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
whorfin 2021-04-28 16:51:02 -07:00 committed by GitHub
parent 771c91d2dd
commit 20d2726faa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 100 additions and 57 deletions

View File

@ -51,7 +51,6 @@ func (e *taglibExtractor) extractMetadata(filePath string) (*taglibMetadata, err
md.tags, err = taglib.Read(filePath) md.tags, err = taglib.Read(filePath)
if err != nil { if err != nil {
log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err) log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, errors.New("error reading tags")
} }
md.hasPicture = hasEmbeddedImage(filePath) md.hasPicture = hasEmbeddedImage(filePath)
return md, nil return md, nil

View File

@ -2,9 +2,10 @@ package scanner
import ( import (
"context" "context"
"io/ioutil" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@ -24,6 +25,26 @@ type (
walkResults = chan dirStats walkResults = chan dirStats
) )
func fullReadDir(name string) ([]os.DirEntry, error) {
f, err := os.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
var allDirs []os.DirEntry
for {
dirs, err := f.ReadDir(-1)
allDirs = append(allDirs, dirs...)
if err == nil {
break
}
log.Warn("Skipping DirEntry", err)
}
sort.Slice(allDirs, func(i, j int) bool { return allDirs[i].Name() < allDirs[j].Name() })
return allDirs, nil
}
func walkDirTree(ctx context.Context, rootFolder string, results walkResults) error { func walkDirTree(ctx context.Context, rootFolder string, results walkResults) error {
err := walkFolder(ctx, rootFolder, rootFolder, results) err := walkFolder(ctx, rootFolder, rootFolder, results)
if err != nil { if err != nil {
@ -49,84 +70,94 @@ func walkFolder(ctx context.Context, rootPath string, currentFolder string, resu
log.Trace(ctx, "Found directory", "dir", dir, "audioCount", stats.AudioFilesCount, log.Trace(ctx, "Found directory", "dir", dir, "audioCount", stats.AudioFilesCount,
"hasImages", stats.HasImages, "hasPlaylist", stats.HasPlaylist) "hasImages", stats.HasImages, "hasPlaylist", stats.HasPlaylist)
stats.Path = dir stats.Path = dir
results <- stats results <- *stats
return nil return nil
} }
func loadDir(ctx context.Context, dirPath string) (children []string, stats dirStats, err error) { func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
var children []string
stats := &dirStats{}
dirInfo, err := os.Stat(dirPath) dirInfo, err := os.Stat(dirPath)
if err != nil { if err != nil {
log.Error(ctx, "Error stating dir", "path", dirPath, err) log.Error(ctx, "Error stating dir", "path", dirPath, err)
return return nil, nil, err
} }
stats.ModTime = dirInfo.ModTime() stats.ModTime = dirInfo.ModTime()
files, err := ioutil.ReadDir(dirPath) dirEntries, err := fullReadDir(dirPath)
if err != nil { if err != nil {
log.Error(ctx, "Error reading dir", "path", dirPath, err) log.Error(ctx, "Error in ReadDir", "path", dirPath, err)
return return children, stats, err
} }
for _, f := range files { for _, entry := range dirEntries {
isDir, err := isDirOrSymlinkToDir(dirPath, f) isDir, err := isDirOrSymlinkToDir(dirPath, entry)
// Skip invalid symlinks // Skip invalid symlinks
if err != nil { if err != nil {
log.Error(ctx, "Invalid symlink", "dir", dirPath) log.Error(ctx, "Invalid symlink", "dir", filepath.Join(dirPath, entry.Name()), err)
continue continue
} }
if isDir && !isDirIgnored(dirPath, f) && isDirReadable(dirPath, f) { if isDir && !isDirIgnored(dirPath, entry) && isDirReadable(dirPath, entry) {
children = append(children, filepath.Join(dirPath, f.Name())) children = append(children, filepath.Join(dirPath, entry.Name()))
} else { } else {
if f.ModTime().After(stats.ModTime) { fileInfo, err := entry.Info()
stats.ModTime = f.ModTime() if err != nil {
log.Error(ctx, "Error getting fileInfo", "name", entry.Name(), err)
return children, stats, err
} }
if utils.IsAudioFile(f.Name()) { if fileInfo.ModTime().After(stats.ModTime) {
stats.ModTime = fileInfo.ModTime()
}
if utils.IsAudioFile(entry.Name()) {
stats.AudioFilesCount++ stats.AudioFilesCount++
} else { } else {
stats.HasPlaylist = stats.HasPlaylist || utils.IsPlaylist(f.Name()) stats.HasPlaylist = stats.HasPlaylist || utils.IsPlaylist(entry.Name())
stats.HasImages = stats.HasImages || utils.IsImageFile(f.Name()) stats.HasImages = stats.HasImages || utils.IsImageFile(entry.Name())
} }
} }
} }
return return children, stats, nil
} }
// isDirOrSymlinkToDir returns true if and only if the dirInfo represents a file // 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 dirInfo // 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 // 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. // sending a request to the operating system to follow the symbolic link.
// Copied from github.com/karrick/godirwalk // originally copied from github.com/karrick/godirwalk, modified to use dirEntry for
func isDirOrSymlinkToDir(baseDir string, dirInfo os.FileInfo) (bool, error) { // efficiency for go 1.16 and beyond
if dirInfo.IsDir() { func isDirOrSymlinkToDir(baseDir string, dirEnt fs.DirEntry) (bool, error) {
if dirEnt.IsDir() {
return true, nil return true, nil
} }
if dirInfo.Mode()&os.ModeSymlink == 0 { if dirEnt.Type()&os.ModeSymlink == 0 {
return false, nil return false, nil
} }
// Does this symlink point to a directory? // Does this symlink point to a directory?
dirInfo, err := os.Stat(filepath.Join(baseDir, dirInfo.Name())) fileInfo, err := os.Stat(filepath.Join(baseDir, dirEnt.Name()))
if err != nil { if err != nil {
return false, err return false, err
} }
return dirInfo.IsDir(), nil return fileInfo.IsDir(), nil
} }
// isDirIgnored returns true if the directory represented by dirInfo contains an // isDirIgnored returns true if the directory represented by dirEnt contains an
// `ignore` file (named after consts.SkipScanFile) // `ignore` file (named after consts.SkipScanFile)
func isDirIgnored(baseDir string, dirInfo os.FileInfo) bool { func isDirIgnored(baseDir string, dirEnt fs.DirEntry) bool {
if strings.HasPrefix(dirInfo.Name(), ".") { // allows Album folders for albums which eg start with ellipses
if strings.HasPrefix(dirEnt.Name(), ".") && !strings.HasPrefix(dirEnt.Name(), "..") {
return true return true
} }
_, err := os.Stat(filepath.Join(baseDir, dirInfo.Name(), consts.SkipScanFile)) _, err := os.Stat(filepath.Join(baseDir, dirEnt.Name(), consts.SkipScanFile))
return err == nil return err == nil
} }
// isDirReadable returns true if the directory represented by dirInfo is readable // isDirReadable returns true if the directory represented by dirEnt is readable
func isDirReadable(baseDir string, dirInfo os.FileInfo) bool { func isDirReadable(baseDir string, dirEnt fs.DirEntry) bool {
path := filepath.Join(baseDir, dirInfo.Name()) path := filepath.Join(baseDir, dirEnt.Name())
res, err := utils.IsDirReadable(path) res, err := utils.IsDirReadable(path)
if !res { if !res {
log.Debug("Warning: Skipping unreadable directory", "path", path, err) log.Warn("Skipping unreadable directory", "path", path, err)
} }
return res return res
} }

View File

@ -10,7 +10,8 @@ import (
. "github.com/onsi/gomega/gstruct" . "github.com/onsi/gomega/gstruct"
) )
var _ = Describe("load_tree", func() { var _ = Describe("walk_dir_tree", func() {
baseDir := filepath.Join("tests", "fixtures")
Describe("walkDirTree", func() { Describe("walkDirTree", func() {
It("reads all info correctly", func() { It("reads all info correctly", func() {
@ -18,7 +19,7 @@ var _ = Describe("load_tree", func() {
results := make(walkResults, 5000) results := make(walkResults, 5000)
var err error var err error
go func() { go func() {
err = walkDirTree(context.TODO(), "tests/fixtures", results) err = walkDirTree(context.TODO(), baseDir, results)
}() }()
for { for {
@ -30,49 +31,61 @@ var _ = Describe("load_tree", func() {
} }
Expect(err).To(BeNil()) Expect(err).To(BeNil())
Expect(collected["tests/fixtures"]).To(MatchFields(IgnoreExtras, Fields{ Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{
"HasImages": BeTrue(), "HasImages": BeTrue(),
"HasPlaylist": BeFalse(), "HasPlaylist": BeFalse(),
"AudioFilesCount": BeNumerically("==", 4), "AudioFilesCount": BeNumerically("==", 4),
})) }))
Expect(collected["tests/fixtures/playlists"].HasPlaylist).To(BeTrue()) Expect(collected[filepath.Join(baseDir, "playlists")].HasPlaylist).To(BeTrue())
Expect(collected).To(HaveKey("tests/fixtures/symlink2dir")) Expect(collected).To(HaveKey(filepath.Join(baseDir, "symlink2dir")))
Expect(collected).To(HaveKey("tests/fixtures/empty_folder")) Expect(collected).To(HaveKey(filepath.Join(baseDir, "empty_folder")))
}) })
}) })
Describe("isDirOrSymlinkToDir", func() { Describe("isDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() { It("returns true for normal dirs", func() {
dir, _ := os.Stat("tests/fixtures") dirEntry, _ := getDirEntry("tests", "fixtures")
Expect(isDirOrSymlinkToDir("tests", dir)).To(BeTrue()) Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeTrue())
}) })
It("returns true for symlinks to dirs", func() { It("returns true for symlinks to dirs", func() {
dir, _ := os.Stat("tests/fixtures/symlink2dir") dirEntry, _ := getDirEntry(baseDir, "symlink2dir")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeTrue()) Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeTrue())
}) })
It("returns false for files", func() { It("returns false for files", func() {
dir, _ := os.Stat("tests/fixtures/test.mp3") dirEntry, _ := getDirEntry(baseDir, "test.mp3")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse()) Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeFalse())
}) })
It("returns false for symlinks to files", func() { It("returns false for symlinks to files", func() {
dir, _ := os.Stat("tests/fixtures/symlink") dirEntry, _ := getDirEntry(baseDir, "symlink")
Expect(isDirOrSymlinkToDir("tests/fixtures", dir)).To(BeFalse()) Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeFalse())
}) })
}) })
Describe("isDirIgnored", func() { Describe("isDirIgnored", func() {
baseDir := filepath.Join("tests", "fixtures")
It("returns false for normal dirs", func() { It("returns false for normal dirs", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "empty_folder")) dirEntry, _ := getDirEntry(baseDir, "empty_folder")
Expect(isDirIgnored(baseDir, dir)).To(BeFalse()) Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse())
}) })
It("returns true when folder contains .ndignore file", func() { It("returns true when folder contains .ndignore file", func() {
dir, _ := os.Stat(filepath.Join(baseDir, "ignored_folder")) dirEntry, _ := getDirEntry(baseDir, "ignored_folder")
Expect(isDirIgnored(baseDir, dir)).To(BeTrue()) Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue())
}) })
It("returns true when folder name starts with a `.`", func() { It("returns true when folder name starts with a `.`", func() {
dir, _ := os.Stat(filepath.Join(baseDir, ".hidden_folder")) dirEntry, _ := getDirEntry(baseDir, ".hidden_folder")
Expect(isDirIgnored(baseDir, dir)).To(BeTrue()) Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue())
})
It("returns false when folder name starts with ellipses", func() {
dirEntry, _ := getDirEntry(baseDir, "...unhidden_folder")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse())
}) })
}) })
}) })
func getDirEntry(baseDir, name string) (os.DirEntry, error) {
dirEntries, _ := os.ReadDir(baseDir)
for _, entry := range dirEntries {
if entry.Name() == name {
return entry, nil
}
}
return nil, os.ErrNotExist
}

View File