From f0fe070ba1d9f45ea08b0c2abf29cd7c2f5e1ca8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:47:43 -0400 Subject: [PATCH] fix(scanner): cap folder images_updated_at at scan time A future-stamped image file (clock skew on NAS mounts) previously flowed verbatim into folder.images_updated_at and from there into the album artwork version, while the stored blur_hash_updated_at is capped at now on write. The DTO staleness gate then kept emitting the fake blurhash until wall time caught up with the file mtime. Capping at the source keeps future values out of the DB entirely; rotation is preserved because any later change is capped to a later scan time. Capping in the DTO instead would be worse: the version would become a moving target and the fake seed would rotate on every request. --- core/artwork/e2e/blurhash_test.go | 3 +++ scanner/walk_dir_tree.go | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core/artwork/e2e/blurhash_test.go b/core/artwork/e2e/blurhash_test.go index d11171aaf..f40e83820 100644 --- a/core/artwork/e2e/blurhash_test.go +++ b/core/artwork/e2e/blurhash_test.go @@ -61,6 +61,9 @@ var _ = Describe("BlurHash", func() { // A future file mtime must be capped at now, or the !Before checks would pin the hash // (and the client's cover cache) until wall time caught up. Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse()) + // The scanner caps the folder's images_updated_at too, so the artwork version is not future + // and the freshly computed hash is accepted by the DTO instead of the fake. + Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse()) }) It("recomputes when the cover is swapped in place", func() { diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 887344b1b..b02ec0dde 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -9,6 +9,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/storage" @@ -160,7 +161,13 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC folder.numPlaylists++ case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry - folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + imagesAt := utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + // Cap at now: a future-stamped image (clock skew) would otherwise become a future artwork + // version that pins the emitted blurhash to the fake until wall time caught up. + if now := time.Now(); imagesAt.After(now) { + imagesAt = now + } + folder.imagesUpdatedAt = imagesAt } } }