From 223efdf0202446fea0fcca0f9203dfbcd1d89bc5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:47:13 -0400 Subject: [PATCH] fix: bust song/disc artwork ids and disc thumbnails on cover changes Song coverArt ids emitted to Subsonic clients embedded no timestamp for the disc/album fallback paths (always `_0`), so clients that cache by coverArt id kept stale art after an album cover upload or delete. Fold the song's CoverArtUpdatedAt into DiscCoverArtID and AlbumCoverArtID; ids are unchanged when no cover was ever uploaded. The web UI disc-header thumbnails had the same gap: getDiscCoverArtUrl only used the song's updatedAt. It now takes the record and picks the newest of updatedAt/coverArtUpdatedAt, sharing the cache-key helper with getCoverArtUrl. --- model/mediafile.go | 4 ++-- model/mediafile_test.go | 13 ++++++++++++ ui/src/common/SongDatagrid.jsx | 13 ++---------- ui/src/subsonic/index.js | 21 +++++++++++++------- ui/src/subsonic/index.test.js | 36 +++++++++++++++++++++++++--------- 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/model/mediafile.go b/model/mediafile.go index 792903b31..8432379a0 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -132,13 +132,13 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), mf.CoverArtUpdatedAt) } return mf.AlbumCoverArtID() } func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, CoverArtUpdatedAt: mf.CoverArtUpdatedAt}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..2462adc84 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -549,6 +549,19 @@ var _ = Describe("MediaFile", func() { Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) + It("folds CoverArtUpdatedAt into disc and album fallback ids", func() { + stamp := time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC) + disc := MediaFile{ID: "111", AlbumID: "1", DiscNumber: 2} + album := MediaFile{ID: "111", AlbumID: "1"} + Expect(disc.CoverArtID().String()).To(HaveSuffix("_0")) + Expect(album.CoverArtID().String()).To(HaveSuffix("_0")) + disc.CoverArtUpdatedAt = &stamp + album.CoverArtUpdatedAt = &stamp + Expect(disc.CoverArtID().LastUpdate).To(Equal(stamp)) + Expect(disc.CoverArtID().String()).ToNot(HaveSuffix("_0")) + Expect(album.CoverArtID().LastUpdate).To(Equal(stamp)) + Expect(album.CoverArtID().String()).ToNot(HaveSuffix("_0")) + }) }) Describe("AudioCodec", func() { diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index d2c98bbe7..6909801ab 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -96,18 +96,9 @@ const DiscSubtitleRow = forwardRef( onClick(discNumber) } - const coverArtUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - 96, - ) + const coverArtUrl = subsonic.getDiscCoverArtUrl(record, 96) - const fullImageUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - ) + const fullImageUrl = subsonic.getDiscCoverArtUrl(record) const handleOpenLightbox = useCallback( (e) => { diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 140774550..778453247 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -80,13 +80,15 @@ const getAvatarUrl = (username, size) => }), ) -const getCoverArtUrl = (record, size, square) => { - // Bust the cache on the newest of updatedAt / coverArtUpdatedAt (album cover - // uploads bump the latter). - const cacheKey = [record.updatedAt, record.coverArtUpdatedAt] +// Newest of updatedAt / coverArtUpdatedAt (album cover uploads bump the latter). +const artCacheKey = (record) => + [record.updatedAt, record.coverArtUpdatedAt] .filter(Boolean) .sort((a, b) => new Date(a) - new Date(b)) .pop() + +const getCoverArtUrl = (record, size, square) => { + const cacheKey = artCacheKey(record) const options = { ...(cacheKey && { _: cacheKey }), ...(size && { size }), @@ -109,13 +111,18 @@ const getCoverArtUrl = (record, size, square) => { } } -const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => { +const getDiscCoverArtUrl = (record, size) => { + const cacheKey = artCacheKey(record) const options = { - ...(updatedAt && { _: updatedAt }), + ...(cacheKey && { _: cacheKey }), ...(size && { size }), } return baseUrl( - url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options), + url( + 'getCoverArt', + 'dc-' + record.albumId + ':' + record.discNumber, + options, + ), ) } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index d1a613081..0f3963852 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -154,9 +154,11 @@ describe('getDiscCoverArtUrl', () => { it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => { const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 2, - '2023-01-01T00:00:00Z', + { + albumId: 'album-123', + discNumber: 2, + updatedAt: '2023-01-01T00:00:00Z', + }, 48, ) @@ -167,7 +169,10 @@ describe('getDiscCoverArtUrl', () => { }) it('should handle missing updatedAt', () => { - const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48) + const url = subsonic.getDiscCoverArtUrl( + { albumId: 'album-123', discNumber: 1 }, + 48, + ) expect(url).toContain('id=dc-album-123%3A1') expect(url).toContain('size=48') @@ -175,16 +180,29 @@ describe('getDiscCoverArtUrl', () => { }) it('should handle missing size', () => { - const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 1, - '2023-01-01T00:00:00Z', - ) + const url = subsonic.getDiscCoverArtUrl({ + albumId: 'album-123', + discNumber: 1, + updatedAt: '2023-01-01T00:00:00Z', + }) expect(url).toContain('id=dc-album-123%3A1') expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') expect(url).not.toContain('size=') }) + + it('should bust the cache on coverArtUpdatedAt when it is newer', () => { + const url = subsonic.getDiscCoverArtUrl({ + albumId: 'album-123', + discNumber: 1, + updatedAt: '2023-01-01T00:00:00Z', + coverArtUpdatedAt: '2024-06-01T00:00:00Z', + }) + + expect(url).toContain('id=dc-album-123%3A1') + expect(url).toContain('_=2024-06-01T00%3A00%3A00Z') + expect(url).not.toContain('_=2023-01-01') + }) }) describe('getAvatarUrl', () => {