From 4e07b292a3632310082c1e37b94a818e7db3e0fa Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 14 Aug 2026 13:35:30 -0400 Subject: [PATCH] feat(cli): add artwork refresh --- cmd/artwork.go | 45 +++++++++++++++++++++++++++++++++++ cmd/artwork_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/cmd/artwork.go b/cmd/artwork.go index e73ed46ed..507bf8b42 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -5,6 +5,8 @@ import ( "context" "errors" "fmt" + "io" + "os" "strings" "text/tabwriter" "time" @@ -28,6 +30,7 @@ func init() { artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, "perform real external lookups instead of reporting what would be tried") artworkCmd.AddCommand(artworkExplainCmd) + artworkCmd.AddCommand(artworkRefreshCmd) rootCmd.AddCommand(artworkCmd) } @@ -49,6 +52,48 @@ var artworkExplainCmd = &cobra.Command{ }, } +var artworkRefreshCmd = &cobra.Command{ + Use: "refresh ...", + Short: "Clear an item's artwork state and re-resolve it", + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + kind, err := parseArtworkKind(args[0]) + if err != nil { + log.Fatal(cmd.Context(), err) + } + runRefresh(cmd.Context(), kind, args[1:]) + }, +} + +func runRefresh(ctx context.Context, kind model.Kind, ids []string) { + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 { + log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids)) + } +} + +// refreshItems keeps going after a failure — the ids are independent — and returns how many failed. +func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int { + var failed int + for _, id := range ids { + // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. + if _, err := artworkItemName(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Item not found", "kind", kind, "id", id, err) + failed++ + continue + } + if err := artwork.Refresh(ctx, ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + failed++ + continue + } + fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id) + } + return failed +} + func parseArtworkKind(s string) (model.Kind, error) { kind, ok := model.ParseKind(s) if ok { diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index a299c5222..bee2e4637 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -1,11 +1,14 @@ package cmd import ( + "context" "errors" + "strings" "time" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -180,3 +183,58 @@ var _ = Describe("formatExplain", func() { "the priority chain config does not govern this kind") }) }) + +var _ = Describe("artwork refresh command", func() { + It("requires at least a kind and one id", func() { + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred()) + }) +}) + +var _ = Describe("refreshItems", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var art *tests.MockArtworkRepo + var out strings.Builder + ctx := context.Background() + + BeforeEach(func() { + albums := tests.CreateMockAlbumRepo() + albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) + ds = &tests.MockDataStore{MockedAlbum: albums} + art = ds.Artwork(ctx).(*tests.MockArtworkRepo) + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + }) + + It("clears the stored state and queues each id at Bump priority", func() { + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), + ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) + + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero()) + + _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) + }) + + It("skips an id that does not exist instead of queuing it", func() { + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1)) + + _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") + Expect(out.String()).To(BeEmpty()) + }) + + It("continues past a failing id and counts the failures", func() { + Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, + []string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1)) + + Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), + "the ids after a failure are still refreshed") + }) +})