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.
This commit is contained in:
Deluan 2026-07-17 22:47:13 -04:00
parent e8946b8810
commit 223efdf020
5 changed files with 58 additions and 29 deletions

View File

@ -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) {

View File

@ -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() {

View File

@ -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) => {

View File

@ -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,
),
)
}

View File

@ -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', () => {