From 5a90e17693a1691b2f2ae366212987c99bb44771 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 02:07:07 -0400 Subject: [PATCH] fix(album): make the cover art id collision-proof against updated_at drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive id (updated_at + cover stamp) could re-emit a previously used value when updated_at decreases (the newest-mtime track leaves the album) by the same delta the cover stamp advanced — re-serving a stale client cover. Mix the timestamps instead, so the id changes whenever either does. --- model/album_test.go | 11 +++++++++++ model/artwork_id.go | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/model/album_test.go b/model/album_test.go index 06c5e64ee..f8df981ba 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -50,6 +50,17 @@ var _ = Describe("Album", func() { a.CoverArtUpdatedAt = &stamp Expect(a.CoverArtID().String()).ToNot(Equal(before)) }) + It("does not collide when updated_at decreases by the cover-stamp delta", func() { + // With an additive combination, updated_at dropping by N while the cover + // stamp advances by N re-emits the same id — the mix must not. + u1 := time.Unix(1_000_000_000, 0) + c1 := time.Unix(2_000_000_000, 0) + u2 := u1.Add(-100 * time.Second) + c2 := c1.Add(100 * time.Second) + id1 := Album{ID: "al-1", UpdatedAt: u1, CoverArtUpdatedAt: &c1}.CoverArtID().String() + id2 := Album{ID: "al-1", UpdatedAt: u2, CoverArtUpdatedAt: &c2}.CoverArtID().String() + Expect(id1).ToNot(Equal(id2)) + }) }) }) diff --git a/model/artwork_id.go b/model/artwork_id.go index d100414b1..6d595f8bf 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -112,11 +112,12 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - // The suffix is a cache discriminator, not a date: cover edits are added (not max'd) - // so the id changes even when updated_at is newer, e.g. files with future mtimes. + // The suffix is a cache discriminator, not a date: mix (not max/add) the timestamps so + // the id changes whenever either does — updated_at can decrease and cancel a plain sum. lastUpdate := al.UpdatedAt if al.CoverArtUpdatedAt != nil { - lastUpdate = time.Unix(max(al.UpdatedAt.Unix(), 0)+al.CoverArtUpdatedAt.Unix(), 0) + mixed := (uint64(al.UpdatedAt.Unix()) ^ (uint64(al.CoverArtUpdatedAt.Unix()) * 0x9E3779B97F4A7C15)) & 0x3FFFFFFFFFFFFFFF + lastUpdate = time.Unix(int64(max(mixed, 1)), 0) } return ArtworkID{ Kind: KindAlbumArtwork,