mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(scanner): read file birth time via statx on Linux On Linux the file birth time is only reachable through statx(2). We were reading it with times.Get(), which looks only at the plain stat() result, where the field does not exist: djherbis/times declares HasBirthTime=false for Linux, so the check was always false and every file fell back to time.Now(). This has been the case since #2553 introduced the feature, which means that PR was a no-op on Linux from day one. macOS and Windows were never affected, as there the birth time does come back from plain stat. BirthTime() now tries times.Get() first, which costs no syscall and is already correct on macOS, Windows and BSD, and only falls back to times.Stat() on the path when that comes back empty. Ordering matters: on Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which fails on a read-only share before falling back. Not every filesystem stores a birth time. Measured with a probe over real mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE never do. Asking those on every file is pure overhead, so a miss is remembered per device on the localFS and skipped from then on. The memo is keyed by device rather than by library, so a library spanning two mounts does not lose birth times on the mount that does support them. Cost of the extra call is ~2us per file against ~52us just to open a file for tag reading, so 0.23s across a 97k-file library, and only for files whose tags are actually read. Existing rows keep their current birth_time: the repository drops that column on update, so only newly added files get the real value. * fix(scanner): return the device id opaquely to satisfy unconvert st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is redundant on one and required on the other. Returning it as an opaque value drops the cast entirely, which also removes the gosec suppression that came with it. The value is only ever used as a sync.Map key.
148 lines
4.2 KiB
Go
148 lines
4.2 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)))
|
|
}
|
|
|
|
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)
|
|
}
|