package cmd import ( "context" "errors" "io" "strings" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "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" ) var _ = Describe("parseArtworkKind", func() { It("accepts a supported kind", func() { k, err := parseArtworkKind("ar") Expect(err).ToNot(HaveOccurred()) Expect(k).To(Equal(model.KindArtistArtwork)) }) It("rejects an unknown kind and lists the valid ones", func() { _, err := parseArtworkKind("zz") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ar")) Expect(err.Error()).To(ContainSubstring("al")) }) It("rejects a known kind that has no artwork command", func() { _, err := parseArtworkKind("mf") Expect(err).To(HaveOccurred()) }) }) var _ = Describe("explainResult", func() { It("reports the winning source", func() { steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder")) }) It("reports not resolved when every candidate was tried and missed", func() { steps := []artwork.TraceStep{ {Candidate: "artist.*", Outcome: "miss"}, {Candidate: "external:deezer", Outcome: "miss"}, } Expect(explainResult("", steps)).To(Equal("not resolved")) }) It("reports indeterminate when an external lookup failed transiently", func() { steps := []artwork.TraceStep{ {Candidate: "artist.*", Outcome: "miss"}, {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, } Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), "a failed network call is not evidence that the item has no artwork") }) It("qualifies a win a skipped higher-priority external candidate could have taken", func() { steps := []artwork.TraceStep{ {Candidate: "external:deezer", Outcome: "would-try"}, {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, } res := explainResult("artist.*", steps) Expect(res).To(ContainSubstring("resolved from artist.*")) Expect(res).To(ContainSubstring("--live"), "offline, the winner is only the winner because the external tier was skipped") }) It("does not qualify a win that no skipped candidate outranked", func() { steps := []artwork.TraceStep{ {Candidate: "artist.*", Outcome: "hit"}, {Candidate: "external:deezer", Outcome: "would-try"}, } Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) }) It("reports indeterminate when external agents were never called", func() { steps := []artwork.TraceStep{ {Candidate: "artist.*", Outcome: "miss"}, {Candidate: "external:deezer", Outcome: "would-try"}, } Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), "an offline run must not claim an item is unresolvable when external agents were skipped") }) }) var _ = Describe("formatExplain", func() { var rep explainReport BeforeEach(func() { rep = explainReport{ kind: model.KindArtistArtwork, id: "ar-1", name: "Radiohead", priorityName: "ArtistArtPriority", priority: "external, artist.*", agents: "lastfm,spotify", steps: []artwork.TraceStep{ {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, {Candidate: "external:deezer", Outcome: "would-try"}, }, source: "", } }) It("reports the item, its config and the chain it walked", func() { out := formatExplain(rep) Expect(out).To(ContainSubstring("Radiohead")) Expect(out).To(ContainSubstring("ar-1")) Expect(out).To(ContainSubstring("ArtistArtPriority")) Expect(out).To(ContainSubstring("lastfm,spotify")) Expect(out).To(ContainSubstring("external:deezer")) Expect(out).To(ContainSubstring("would-try")) Expect(out).To(ContainSubstring("indeterminate")) }) It("reports the absence of stored state and of a queue row", func() { out := formatExplain(rep) Expect(out).To(ContainSubstring("no artwork state recorded")) Expect(out).To(ContainSubstring("not queued")) }) It("prints the stored state and the queue row when they exist", func() { attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123", SourcePath: "/music/cover.jpg", AttemptedAt: attempted} rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2, RetryAt: attempted.Add(time.Hour)} rep.source = "folder" out := formatExplain(rep) Expect(out).To(ContainSubstring("abc123")) Expect(out).To(ContainSubstring("/music/cover.jpg")) Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z")) Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up") Expect(out).To(ContainSubstring("resolved from folder")) }) It("marks a known-absent stored state instead of printing an empty hash", func() { rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()} Expect(formatExplain(rep)).To(ContainSubstring("absent")) }) It("reports a failed walk as failed, not as unresolved", func() { rep.resolveErr = errors.New("no such directory") out := formatExplain(rep) Expect(out).To(ContainSubstring("resolution failed: no such directory")) Expect(out).ToNot(ContainSubstring("indeterminate")) Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print") }) It("says a kind that does not walk a chain has no chain, without an empty table", func() { rep.kind = model.KindPlaylistArtwork rep.steps = nil rep.priorityName, rep.priority = "CoverArtPriority", "cover.*, embedded" out := formatExplain(rep) Expect(out).To(ContainSubstring("does not walk a priority chain")) Expect(out).ToNot(ContainSubstring("CANDIDATE"), "an empty chain table reads as 'nothing was tried', which is false") Expect(out).ToNot(ContainSubstring("not resolved"), "nothing was resolved because nothing was attempted") Expect(out).ToNot(ContainSubstring("CoverArtPriority"), "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("artwork reprocess selection", func() { It("errors when no selector is given", func() { _, err := selectedKinds(nil, nil, false) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("--all")) }) It("returns every kind for --all", func() { ks, err := selectedKinds(nil, nil, true) Expect(err).ToNot(HaveOccurred()) Expect(ks).To(ConsistOf(artwork.RecheckKinds)) }) It("returns every kind for a source filter given without a kind", func() { ks, err := selectedKinds(nil, []string{"folder"}, false) Expect(err).ToNot(HaveOccurred()) Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection") }) It("returns only the named kinds", func() { ks, err := selectedKinds([]string{"ar"}, nil, false) Expect(err).ToNot(HaveOccurred()) Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) }) It("keeps a named kind selection alongside a source filter", func() { ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false) Expect(err).ToNot(HaveOccurred()) Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) }) It("rejects an unknown kind", func() { _, err := selectedKinds([]string{"zz"}, nil, false) Expect(err).To(HaveOccurred()) }) It("counts a repeated kind once", func() { ks, err := selectedKinds([]string{"ar", "ar"}, nil, false) Expect(err).ToNot(HaveOccurred()) Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) }) }) var _ = Describe("explain/reprocess source round trip", func() { ctx := context.Background() // storedSource reads back the Source line explain printed, as an operator would copy it. storedSource := func(out string) string { GinkgoHelper() for line := range strings.SplitSeq(out, "\n") { if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok { return strings.TrimSpace(after) } } Fail("explain printed no Source line") return "" } It("names the absent state as reprocess --source accepts it", func() { ds := &tests.MockDataStore{} art := ds.Artwork(ctx).(*tests.MockArtworkRepo) Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) q := ds.ArtworkQueue(ctx) Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(), "explain's spelling of a source must be pasteable into --source") Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), "a parenthesised name would be rejected, so explain must not print one") }) }) var _ = Describe("repositorySources", func() { It("maps the user-facing absent name onto the stored empty source", func() { Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) }) It("keeps an empty selection empty, meaning every source", func() { Expect(repositorySources(nil)).To(BeEmpty()) }) }) var _ = Describe("promptConfirm", func() { var out strings.Builder BeforeEach(func() { out.Reset() }) It("states the external cost and accepts an explicit yes", func() { Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue()) Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) }) It("defaults to no on anything else", func() { Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse()) Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse()) Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse()) }) It("drops the external clause when no lookup will be made", func() { Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue()) Expect(out.String()).To(ContainSubstring("re-resolve 3 items.")) Expect(out.String()).ToNot(ContainSubstring("External lookups")) }) }) var _ = Describe("reprocessConfirm", func() { var out strings.Builder BeforeEach(func() { out.Reset() }) It("prompts when --yes was not given", func() { Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse()) Expect(out.String()).To(ContainSubstring("Continue?")) }) It("bypasses the prompt only for --yes", func() { Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue()) Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") }) }) var _ = Describe("reprocessArtwork", func() { var ds *tests.MockDataStore var art *tests.MockArtworkRepo var queue *tests.MockArtworkQueueRepo var out strings.Builder var imageAgents artwork.ImageAgentCount ctx := context.Background() kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} accept := func(io.Writer, int64, int64) bool { return true } decline := func(io.Writer, int64, int64) bool { return false } put := func(kind model.Kind, id, source string) { Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) } BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.CoverArtPriority = "cover.*, external" conf.Server.ArtistArtPriority = "artist.*, external" conf.Server.EnableM3UExternalAlbumArt = false imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} ds = &tests.MockDataStore{} art = ds.Artwork(ctx).(*tests.MockArtworkRepo) queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) out.Reset() put(model.KindArtistArtwork, "ar-1", "external:deezer") put(model.KindArtistArtwork, "ar-2", "") put(model.KindAlbumArtwork, "al-1", "external:deezer") put(model.KindAlbumArtwork, "al-2", "folder") }) It("previews the per-kind breakdown and queues nothing on a dry run", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("external:deezer")) Expect(out.String()).To(ContainSubstring("artist")) Expect(out.String()).To(ContainSubstring("album")) Expect(out.String()).To(ContainSubstring("TOTAL")) Expect(out.String()).To(ContainSubstring("Dry run")) Expect(queue.Count()).To(BeZero()) }) It("queues nothing when the operator declines", func() { Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Aborted")) Expect(queue.Count()).To(BeZero()) }) It("queues the matching items at recheck priority, leaving their artwork state alone", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) Expect(queue.Count()).To(Equal(int64(2))) queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) _, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") }) It("targets the absent state", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) Expect(queue.Count()).To(Equal(int64(1))) _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) }) It("reports matched and queued separately when part of the set is already queued", func() { Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), "an already-queued row keeps its priority and backoff") }) It("stops at a selection that matches nothing instead of prompting", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false, func(io.Writer, int64, int64) bool { Fail("must not prompt when there is nothing to queue") return true }, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Nothing")) Expect(queue.Count()).To(BeZero()) }) It("reports an empty selection as a dry run when one was asked for", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Nothing matches")) Expect(out.String()).To(ContainSubstring("Dry run")) }) It("shows the external estimate on a dry run, which never reaches the prompt", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated")) }) It("bills every agent per item, not one lookup per item", func() { imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3} var external int64 capture := func(_ io.Writer, _, e int64) bool { external = e; return false } Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed()) Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents") Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated")) }) It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("plugin agents not counted")) Expect(out.String()).To(ContainSubstring("local hits may need fewer")) Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") }) It("says so when the selection needs no external lookup", func() { conf.Server.CoverArtPriority = "cover.*" put(model.KindPlaylistArtwork, "pl-1", "playlist") Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("External lookups: none")) }) It("counts playlists as external cost when the m3u image fetch is enabled", func() { conf.Server.CoverArtPriority = "cover.*" conf.Server.EnableM3UExternalAlbumArt = true put(model.KindPlaylistArtwork, "pl-1", "playlist") var external int64 capture := func(_ io.Writer, _, e int64) bool { external = e; return false } Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) Expect(external).To(Equal(int64(1))) Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated")) }) It("bills a playlist for every album its grid samples, at every agent", func() { imageAgents = artwork.ImageAgentCount{Album: 3} put(model.KindPlaylistArtwork, "pl-1", "playlist") var external int64 capture := func(_ io.Writer, _, e int64) bool { external = e; return false } Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)), "one playlist samples 4 albums, each walking all 3 album agents") }) It("counts only the kinds that call an external agent as external cost", func() { put(model.KindRadioArtwork, "ra-1", "upload") var total, external int64 capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false } Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) Expect(total).To(Equal(int64(3))) Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent") }) It("rejects an unknown source and names the ones in use", func() { err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("externa:deezer")) Expect(err.Error()).To(ContainSubstring("external:deezer")) Expect(err.Error()).To(ContainSubstring("folder")) Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") Expect(queue.Count()).To(BeZero()) }) It("accepts a source another kind uses, letting the empty selection report itself", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, imageAgents, false, decline, &out)).To(Succeed()) Expect(out.String()).To(ContainSubstring("Nothing matches"), "a well-formed filter must not be reported as a typo because of the kinds selected") Expect(queue.Count()).To(BeZero()) }) }) var _ = Describe("artwork status command", func() { It("takes no arguments", func() { Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred()) Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred()) }) }) var _ = Describe("collectStatus", func() { var ds *tests.MockDataStore var art *tests.MockArtworkRepo var queue *tests.MockArtworkQueueRepo ctx := context.Background() BeforeEach(func() { ds = &tests.MockDataStore{} art = ds.Artwork(ctx).(*tests.MockArtworkRepo) queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) put := func(kind model.Kind, id, source, hash string, attempted time.Time) { Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour)) put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) }) It("reports the queue, the source distribution and the absent ages", func() { rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 1})) Expect(rep.sources).To(ContainElements( sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1}, sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, )) Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}})) }) It("compares the stored fingerprint against the current one", func() { Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) Expect(rep.stored).To(Equal("old-fingerprint")) Expect(rep.current).To(Equal(artwork.ConfigFingerprint()), "the CLI must report the value backfill itself compares") }) It("queues nothing", func() { _, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything") }) }) var _ = Describe("formatStatus", func() { var rep statusReport BeforeEach(func() { rep = statusReport{ queue: []model.ArtworkQueueStat{ {ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, {ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, }, sources: []sourceCount{ {kind: model.KindArtistArtwork, source: "external:deezer", count: 5}, {kind: model.KindArtistArtwork, source: "", count: 2}, }, absent: []absentCount{ {kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}, }, inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, stored: "abc123", current: "abc123", } }) // block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere. block := func(out, header string) string { GinkgoHelper() _, after, found := strings.Cut(out, header+"\n") Expect(found).To(BeTrue(), "the %q block must be printed", header) body, _, _ := strings.Cut(after, "\n\n") return body } It("names the kind and the priority of every queued row", func() { queue := block(formatStatus(rep), "Queue") Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`)) Expect(queue).To(MatchRegexp(`album\s+scan\s+1`)) }) It("totals the queue", func() { Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`)) }) It("counts each source, naming the empty one absent", func() { sources := block(formatStatus(rep), "Sources") Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`)) Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) }) It("prints the absent total and how many are due for recheck", func() { absent := block(formatStatus(rep), "Absent (resolved, no image found)") Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) }) It("states the recheck window the absent counts are bucketed against", func() { Expect(formatStatus(rep)).To(ContainSubstring("24h")) }) It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { out := block(formatStatus(rep), "Backfill") Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`), "an operator scanning for trouble must not read 'up to date' while 2 items churn") Expect(out).To(ContainSubstring("fingerprint up to date")) }) It("keeps the re-enqueue warning while a backfill is already running", func() { rep.stored = "older" out := block(formatStatus(rep), "Backfill") Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`)) Expect(out).To(ContainSubstring("re-enqueued"), "the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one") }) It("reports up to date only once the backfill has drained", func() { rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}} Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`)) }) It("echoes the config inputs a fingerprint change would have come from", func() { out := block(formatStatus(rep), "Backfill") Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") }) It("reports a changed fingerprint as a pending re-resolve of everything", func() { rep.stored = "older" rep.queue = nil out := formatStatus(rep) Expect(out).To(ContainSubstring("fingerprint changed")) Expect(out).ToNot(ContainSubstring("up to date")) }) It("reports a never-recorded fingerprint without printing an empty value", func() { rep.stored = "" out := formatStatus(rep) Expect(out).To(ContainSubstring("(none)")) Expect(out).To(ContainSubstring("fingerprint changed")) }) It("says the queue is empty instead of printing a headless table", func() { rep.queue = nil out := formatStatus(rep) Expect(out).To(ContainSubstring("empty")) Expect(out).ToNot(ContainSubstring("PRIORITY")) }) }) 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") }) })