mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-21 08:47:16 +02:00
* fix(scanner): respect FollowSymlinks in watcher-triggered scans With FollowSymlinks disabled, creating a folder symlink inside the library made the watcher schedule a selective scan with the link itself as the target. walkDirTree only checked FollowSymlinks for child entries, so it walked the link and imported its files as duplicates (or, for links that point outside the library, files that should never be scanned). walkDirTree now skips any target folder whose path, or any parent folder, is a symlink when FollowSymlinks is disabled. The skipped target stays in lastUpdates, so rows previously imported through it are marked missing, matching what a full scan does. localFS now implements fs.ReadLinkFS so fs.Lstat can see symlinks instead of following them. Fixes #6292 * test(scanner): run the #6292 symlinked target tests on Windows Remove the SkipOnWindows guard from the symlinked target folder tests, so the go-windows CI job covers the FollowSymlinks fix for selective scans.
158 lines
4.5 KiB
Go
158 lines
4.5 KiB
Go
package local
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/djherbis/times"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/storage"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model/metadata"
|
|
)
|
|
|
|
// localStorage implements a Storage that reads the files from the local filesystem and uses registered extractors
|
|
// to extract the metadata and tags from the files.
|
|
type localStorage struct {
|
|
u url.URL
|
|
extractor Extractor
|
|
resolvedPath string
|
|
watching atomic.Bool
|
|
}
|
|
|
|
func newLocalStorage(u url.URL) storage.Storage {
|
|
newExtractor, ok := extractors[conf.Server.Scanner.Extractor]
|
|
if !ok || newExtractor == nil {
|
|
if conf.Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
|
log.Warn("Extractor not found, using default", "extractor", conf.Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
|
}
|
|
newExtractor = extractors[consts.DefaultScannerExtractor]
|
|
if newExtractor == nil {
|
|
log.Fatal("Default extractor not registered", "extractor", consts.DefaultScannerExtractor)
|
|
}
|
|
}
|
|
isWindowsPath := filepath.VolumeName(u.Host) != ""
|
|
if u.Scheme == storage.LocalSchemaID && isWindowsPath {
|
|
u.Path = filepath.Join(u.Host, u.Path)
|
|
}
|
|
resolvedPath, err := filepath.EvalSymlinks(u.Path)
|
|
if err != nil {
|
|
log.Warn("Error resolving path", "path", u.Path, "err", err)
|
|
resolvedPath = u.Path
|
|
}
|
|
return &localStorage{u: u, extractor: newExtractor(os.DirFS(u.Path), u.Path), resolvedPath: resolvedPath}
|
|
}
|
|
|
|
func (s *localStorage) FS() (storage.MusicFS, error) {
|
|
path := s.u.Path
|
|
if _, err := os.Stat(path); err != nil { //nolint:gosec
|
|
return nil, fmt.Errorf("%w: %s", err, path)
|
|
}
|
|
return &localFS{FS: os.DirFS(path), extractor: s.extractor, root: path}, nil
|
|
}
|
|
|
|
type localFS struct {
|
|
fs.FS
|
|
extractor Extractor
|
|
root string
|
|
// devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once
|
|
noBirthTime sync.Map
|
|
}
|
|
|
|
// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the
|
|
// OS level, so links whose targets live outside the library folder (not reachable through
|
|
// the fs.FS abstraction) still resolve to their final target.
|
|
func (lfs *localFS) ResolveSymlink(name string) (string, error) {
|
|
if !fs.ValidPath(name) {
|
|
return "", &fs.PathError{Op: "resolvesymlink", Path: name, Err: fs.ErrInvalid}
|
|
}
|
|
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
|
|
}
|
|
|
|
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
|
|
var _ fs.ReadLinkFS = (*localFS)(nil)
|
|
|
|
func (lfs *localFS) ReadLink(name string) (string, error) {
|
|
return fs.ReadLink(lfs.FS, name)
|
|
}
|
|
|
|
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
|
|
return fs.Lstat(lfs.FS, name)
|
|
}
|
|
|
|
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
|
|
res, err := lfs.extractor.Parse(path...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for path, v := range res {
|
|
if v.FileInfo == nil {
|
|
info, err := fs.Stat(lfs, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v.FileInfo = localFileInfo{
|
|
FileInfo: info,
|
|
path: filepath.Join(lfs.root, filepath.FromSlash(path)),
|
|
noBirthTime: &lfs.noBirthTime,
|
|
}
|
|
res[path] = v
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// localFileInfo is a wrapper around fs.FileInfo that adds a BirthTime method, to make it compatible
|
|
// with metadata.FileInfo
|
|
type localFileInfo struct {
|
|
fs.FileInfo
|
|
path string
|
|
noBirthTime *sync.Map
|
|
}
|
|
|
|
func (lfi localFileInfo) BirthTime() time.Time {
|
|
if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() {
|
|
return ts.BirthTime()
|
|
}
|
|
if bt, ok := lfi.statxBirthTime(); ok {
|
|
return bt
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it.
|
|
// Filesystems that never report one are remembered per device, so a scan asks each only once.
|
|
func (lfi localFileInfo) statxBirthTime() (time.Time, bool) {
|
|
if lfi.path == "" {
|
|
return time.Time{}, false
|
|
}
|
|
dev, hasDev := deviceID(lfi.FileInfo)
|
|
memo := lfi.noBirthTime
|
|
if hasDev && memo != nil {
|
|
if _, skip := memo.Load(dev); skip {
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
ts, err := times.Stat(lfi.path)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
if ts.HasBirthTime() {
|
|
return ts.BirthTime(), true
|
|
}
|
|
if hasDev && memo != nil {
|
|
memo.Store(dev, struct{}{})
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func init() {
|
|
storage.Register(storage.LocalSchemaID, newLocalStorage)
|
|
}
|