diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go new file mode 100644 index 000000000..42e7dac70 --- /dev/null +++ b/core/storage/local/deviceid_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package local + +import ( + "io/fs" + "syscall" +) + +// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. +// It is returned opaquely because its width varies by platform, and it is only used as a map key. +func deviceID(fi fs.FileInfo) (any, bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false + } + return st.Dev, true +} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go new file mode 100644 index 000000000..f1b44be4d --- /dev/null +++ b/core/storage/local/deviceid_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package local + +import "io/fs" + +// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. +func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 32aff0955..686838565 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "sync" "sync/atomic" "time" @@ -61,6 +62,8 @@ 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 @@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{info} + v.FileInfo = localFileInfo{ + FileInfo: info, + path: filepath.Join(lfs.root, filepath.FromSlash(path)), + noBirthTime: &lfs.noBirthTime, + } res[path] = v } } @@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // 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) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 90bdd4b5b..3ed6d6fd9 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" + "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) + + It("reads the birth time from the path, not the time of the call", func() { + // On Linux, birth time is only available via statx(2) on the path. + lfi := localFileInfo{FileInfo: fileInfo, path: testFile} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) + }) + + It("does not remember filesystems that do report a birth time", func() { + memo := &sync.Map{} + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + lfi.BirthTime() + + count := 0 + memo.Range(func(_, _ any) bool { count++; return true }) + Expect(count).To(BeZero()) + }) + + It("skips statx on filesystems already known to have none", func() { + if times.Get(fileInfo).HasBirthTime() { + Skip("this platform reports birth time from FileInfo, so statx is never called") + } + dev, ok := deviceID(fileInfo) + Expect(ok).To(BeTrue()) + + memo := &sync.Map{} + memo.Store(dev, struct{}{}) + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + }) }) It("should delegate all other FileInfo methods", func() {