fix(ui): bust song artwork URLs when the album cover changes

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.
This commit is contained in:
Deluan 2026-07-17 22:03:31 -04:00
parent 35d08a25a3
commit e8946b8810
5 changed files with 35 additions and 6 deletions

View File

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

View File

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

View File

@ -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"}

View File

@ -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" +

View File

@ -91,6 +91,7 @@ const mapToAudioLists = (item) => {
{
id: trackId,
updatedAt: item.updatedAt,
coverArtUpdatedAt: item.coverArtUpdatedAt,
album: item.album,
},
300,