From d94bf0953eb0c055215f5b7c49b4ad02c5cc5c57 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Jul 2026 22:37:38 -0400 Subject: [PATCH] fix(artwork): refresh songs when their album's artwork changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A track with no art of its own is served its album's, so hydration copies the album's hash onto the track record. When an album resolution changed, the worker broadcast only an `album` refresh, leaving song and now-playing surfaces holding the previous hash-suffixed URL until something else refetched them. Pair the album refresh with a song one. The dependent id list is unbounded — an album has arbitrarily many tracks and a drained batch arbitrarily many albums — so this refreshes the resource as a whole via the protocol's existing wildcard rather than enumerating ids. Reported by Codex on #5847. --- core/artwork/worker.go | 7 +++++++ core/artwork/worker_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index fab0b96be..499f407d8 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -250,6 +250,13 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu for res, ids := range byResource { event = event.With(res, ids...) } + // A track with no art of its own is served its album's, so an album change moves the track's + // hash too. The dependent id list is unbounded, so refresh the resource as a whole. + if _, ok := byResource["album"]; ok { + if _, ok := byResource["song"]; !ok { + event = event.With("song") + } + } w.broker.SendBroadcastMessage(ctx, event) } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index bf5bf628e..e2aa8bf82 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/goleak" @@ -439,8 +440,32 @@ var _ = Describe("Worker", func() { Expect(data).To(ContainSubstring("al2")) Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") Expect(data).ToNot(ContainSubstring("ar1")) + Expect(data).To(ContainSubstring(`"song"`), "tracks with no art of their own are served the album's") }) + DescribeTable("only pairs songs with album refreshes", + func(kinds []string, wantSong bool) { + items := slice.Map(kinds, func(k string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"} + }) + w.broadcastRefresh(ctx, items) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1)) + data := evts[0].(*events.RefreshResource).Data(evts[0]) + if wantSong { + Expect(data).To(ContainSubstring(`"song"`)) + } else { + Expect(data).ToNot(ContainSubstring(`"song"`)) + } + }, + Entry("album alone drags songs along", []string{"al"}, true), + Entry("artist alone does not", []string{"ar"}, false), + Entry("playlist alone does not", []string{"pl"}, false), + Entry("album mixed with others still does", []string{"ar", "al"}, true), + Entry("songs resolving on their own stay single-listed", []string{"mf"}, true), + ) + It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})