From d509d8b2f862ca3813fbfb409f2ad7ca79155c78 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 02:18:52 -0400 Subject: [PATCH] fix(subsonic): expose cover cache-buster on bookmarked songs GetBookmarks selected media_file.* without the album cover-stamp projection, so bookmarked songs kept emitting the pre-edit coverArt id after a cover upload/delete. With this fourth copy of the projection appearing, extract it into a shared coverArtUpdatedAtCol helper used by all media_file read paths (list, playlist tracks, single playlist track, bookmarks). --- persistence/helpers.go | 6 ++++++ persistence/mediafile_repository.go | 4 +--- persistence/playlist_repository.go | 2 +- persistence/playlist_track_repository.go | 2 +- persistence/sql_bookmarks.go | 4 +++- persistence/sql_bookmarks_test.go | 20 ++++++++++++++++++++ 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/persistence/helpers.go b/persistence/helpers.go index fd6a9a4cd..efc676445 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -90,3 +90,9 @@ func mapSortOrder(tableName, order string) string { repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) return sortOrderRegex.ReplaceAllString(order, repl) } + +// coverArtUpdatedAtCol projects the album's manual-cover timestamp onto rows of the given +// table/alias as a scalar subquery — a join would clash with media_file's column names. +func coverArtUpdatedAtCol(alias string) string { + return "(select cover_art_updated_at from album where album.id = " + alias + ".album_id) as cover_art_updated_at" +} diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index a973ed6e4..9d11b96b9 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -184,10 +184,8 @@ func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { } func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - // 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"). + coverArtUpdatedAtCol("media_file")). 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/playlist_repository.go b/persistence/playlist_repository.go index 0833ec72a..435823561 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -314,7 +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", + coverArtUpdatedAtCol("f"), ). LeftJoin("annotation on (" + "annotation.item_id = media_file_id" + diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 6f409101d..6580643cd 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -108,7 +108,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) { "rated_at", "f.*", "playlist_tracks.*", - "(select cover_art_updated_at from album where album.id = f.album_id) as cover_art_updated_at", + coverArtUpdatedAtCol("f"), ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 19f16b231..16d97e6d9 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -100,7 +100,9 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { user, _ := request.UserFrom(r.ctx) idField := r.tableName + ".id" - sq := r.newSelect().Columns(r.tableName + ".*") + // Only media files are bookmarkable (see dbMediaFiles scan below), so the cover + // stamp projection is safe to add here + sq := r.newSelect().Columns(r.tableName+".*", coverArtUpdatedAtCol(r.tableName)) sq = r.withAnnotation(sq, idField) sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) var mfs dbMediaFiles // TODO Decouple from media_file diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index 712a928db..6757a04e5 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -70,5 +71,24 @@ var _ = Describe("sqlBookmarks", func() { Expect(mr.DeleteBookmark(songComeTogether.ID)).To(Succeed()) Expect(mr.GetBookmarks()).To(BeEmpty()) }) + + It("exposes the album's cover timestamp on bookmarked songs", func() { + albumRepo := NewAlbumRepository(request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}), GetDBXBuilder()).(*albumRepository) + Expect(mr.AddBookmark(songAntenna.ID, "cover test", 1)).To(Succeed()) + DeferCleanup(func() { + _ = mr.DeleteBookmark(songAntenna.ID) + // Restore both columns so fixture-equality tests stay untouched + _, _ = albumRepo.executeSQL(squirrel.Update("album"). + Set("uploaded_image", "").Set("cover_art_updated_at", nil). + Where(squirrel.Eq{"id": songAntenna.AlbumID})) + }) + + Expect(albumRepo.UpdateImage(songAntenna.AlbumID, "cover-bm.jpg")).To(Succeed()) + + bms, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.CoverArtUpdatedAt).ToNot(BeNil()) + }) }) })