From e8946b88109d87e77197c1a655c5d12557256096 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 22:03:31 -0400 Subject: [PATCH] fix(ui): bust song artwork URLs when the album cover changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Song art URLs (mf- ids: player bar, queue) were cache-busted only by the song's own updatedAt, and cover responses carry a 10-year max-age — so after an album cover upload/delete the browser kept serving the stale cached image (including the placeholder, for previously artless albums) until the file itself was rescanned. Expose the album's cover_art_updated_at on song payloads via a scalar subquery (a join would make unqualified references ambiguous — media_file and album share column names), in both the media_file select and the playlist tracks query, and pass it through the player queue builder so getCoverArtUrl picks it up. --- model/mediafile.go | 13 ++++++++----- persistence/mediafile_repository.go | 5 ++++- persistence/mediafile_repository_test.go | 21 +++++++++++++++++++++ persistence/playlist_repository.go | 1 + ui/src/reducers/playerReducer.js | 1 + 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..792903b31 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -30,11 +30,14 @@ type MediaFile struct { LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"` LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` - FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` - Path string `structs:"path" json:"path" hash:"ignore"` - Title string `structs:"title" json:"title"` - Album string `structs:"album" json:"album"` - ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead + // CoverArtUpdatedAt mirrors the album's manual-cover timestamp so clients can + // bust song artwork URLs that resolve to the album cover. + CoverArtUpdatedAt *time.Time `structs:"-" json:"coverArtUpdatedAt,omitempty" hash:"ignore"` + FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` + Path string `structs:"path" json:"path" hash:"ignore"` + Title string `structs:"title" json:"title"` + Album string `structs:"album" json:"album"` + ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead // Artist is the display name used for the artist. Artist string `structs:"artist" json:"artist"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..a973ed6e4 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -184,7 +184,10 @@ func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { } func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). + // Scalar subquery (not a join): album shares column names with media_file, and a join + // would make unqualified filter/sort references ambiguous. + sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name", + "(select cover_art_updated_at from album where album.id = media_file.album_id) as cover_art_updated_at"). LeftJoin("library on media_file.library_id = library.id") sql = r.withAnnotation(sql, "media_file.id") sql = r.withBookmark(sql, "media_file.id") diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index f6a744d8d..8d2f45f1b 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,27 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("CoverArtUpdatedAt", func() { + It("exposes the album's cover timestamp on its songs", func() { + albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()) + Expect(albumRepo.Put(&model.Album{ID: "cover-al", Name: "cover", LibraryID: 1})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "cover-mf", LibraryID: 1, AlbumID: "cover-al", Path: "test/cover-mf.mp3"})).To(Succeed()) + DeferCleanup(func() { + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": "cover-mf"})) + _, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "cover-al"})) + }) + + got, err := mr.Get("cover-mf") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt).To(BeNil()) + + Expect(albumRepo.UpdateImage("cover-al", "cover-al_x.jpg")).To(Succeed()) + got, err = mr.Get("cover-mf") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CoverArtUpdatedAt).ToNot(BeNil()) + }) + }) + Describe("GetCursor", func() { It("yields the same media files as GetAll", func() { opts := model.QueryOptions{Sort: "title"} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..0833ec72a 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -314,6 +314,7 @@ func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectB "playlist_tracks.*", "library.path as library_path", "library.name as library_name", + "(select cover_art_updated_at from album where album.id = f.album_id) as cover_art_updated_at", ). LeftJoin("annotation on (" + "annotation.item_id = media_file_id" + diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d6ab7484b..4594343c4 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -91,6 +91,7 @@ const mapToAudioLists = (item) => { { id: trackId, updatedAt: item.updatedAt, + coverArtUpdatedAt: item.coverArtUpdatedAt, album: item.album, }, 300,