navidrome/cmd/artwork_test.go
Deluan Quintão 5b87a60b5d
fix(plugins): load plugin agents in CLI commands (#5959)
* feat(plugins): load plugin agents in CLI commands

A CLI that goes through core/agents saw only built-in agents: getEnabledAgentNames asks
Manager.PluginNames, which reads a map populated solely by Manager.Start, and only the
server calls that. On a plugin-using install the CLI's agent list was quietly short —
artwork explain --live could name deezer for an artist whose stored source was
external:apple-music, because apple-music was invisible to it.

Adds Manager.LoadPlugins, the read-only counterpart to Start: extism/wazero init plus
loadEnabledPlugins, without the folder sync, the error clearing, the cache purge or the
watcher. It follows what the read-only plugin commands already do — list, info and
validate read the DB and never start the manager — except that capabilities are detected
from the WASM exports, not declared in the manifest, so instantiating is the only accurate
source for what a plugin provides.

loadEnabledPlugins disabled a plugin and recorded LastError when a load failed. That is
right for the server and wrong for a diagnostic, so it is now gated on the read-only flag:
inspecting a plugin must not disable it.

No Subsonic router is required. Start log.Fatals without one, but the host function it
feeds already nil-checks and reports 'SubsonicAPI router not available' at call time, so a
plugin that reaches for it gets an error instead of the process dying. That Fatal's message
also claimed the DataStore was missing; it checks the router.

* fix(cli): correct the reprocess estimate's plugin caveat

imageAgentCount now receives a manager with plugins loaded, so the external estimate
already includes plugin image agents — but the disclaimer still said they were not
counted, which told operators the opposite of what the number meant.

Replaced rather than dropped: loadPluginAgents warns and continues when LoadPlugins
fails, and LoadPlugins is a no-op when plugins are disabled or no folder is set, so
there are still runs where plugin agents genuinely are not counted. The wording now
covers all three cases, and the stale comment above it said the CLI never starts the
plugin manager, which is what this branch changed.

Found by Codex on 648cf38e9.

* fix(plugins): load only the configured agents, and gate plugin init

Loading a plugin is not free: the service constructors create a KVStore or TaskQueue
database and a Storage directory for any plugin whose manifest declares those
permissions, and the plugin's own init then runs arbitrary code. loadEnabledPlugins
loads every enabled plugin, so inspecting artwork was starting scrobblers, schedulers
and lyrics plugins that could never supply an image.

Measured on a copy of a production library: 'artwork explain' created
apple-music/kvstore.db, nd-lyrics/kvstore.db and listenbrainz-daily-playlist/taskqueue.db.
The last one matters most — CreateQueue resets rows with status='running' to 'pending',
which against a live server sets up its in-flight tasks to run twice.

LoadPlugins now takes the names to load, and the artwork CLI passes the Agents list: a
plugin that is not a configured agent can never win, so there is nothing to gain by
instantiating it. The same two runs now create only apple-music, which is a configured
agent and therefore the cost of answering the question.

Init is gated separately on the caller's intent rather than on read-only. 'explain --live'
already means 'reach the provider', so it runs init; plain 'explain' and 'reprocess'
promise no external requests and must not. Documented in the --live flag help.

Found by Codex on 28eb39ac4.

* perf(cli): load plugin agents only when the selection can consult one

Explaining disc or media file artwork loaded every configured metadata plugin, though
neither resolver ever reaches an agent: resolveMediaFile is embedded-only and
discArtworkReader.selectImage refuses external outright. With --live that also ran plugin
init for a walk that provably cannot reach the network. The load now sits inside the
artist/album branch that already exists, so it is a move rather than a new condition.

reprocess did the same for a radio-only selection, whose estimate is unconditionally zero.
It is gated on needsImageAgents, which asks exactly what ExternalLookupsPerItem asks, so
the two cannot disagree. An artist/album test would look equivalent and would silently
zero the playlist estimate, whose generated grid resolves album art through those agents;
a test pins that, and reverting the predicate to a whitelist fails it.

Found by Codex on b78e67b07.

* docs(artwork): trim the comments this branch added to the project budget

Six blocks ran past the one-to-two line limit. The LoadPlugins doc was eleven lines over
three paragraphs, needsImageAgents spent two of its four explaining an alternative that was
rejected, and inspectOpts restated what LoadPlugins already says.

What went is reviewer-facing prose that belongs in a commit message: the enumeration of
what Start does that this skips, and why an artist/album predicate would have been wrong.
What stayed is the reasoning a future reader needs at that line, notably that instantiating
a plugin creates its declared services, and that playlists consume the album agent count.

* docs(artwork): correct the breaker comment after the recovery ramp

It still said a success re-closes the breaker, which stopped being true when closing
started requiring breakerRecoveries consecutive answers.

* refactor(plugins): rename the scoped-load options to transientLoad

inspect claimed the load was only looking, which is false when runInit is true: it
instantiates the plugin and runs its init, which may open sockets. transient is accurate
for every use of the field, and explains all three behaviours it gates. A load that will
not outlive the command has no business persisting findings, instantiating plugins it will
never consult, or starting background work it is about to tear down.
2026-08-15 11:01:36 -04:00

947 lines
38 KiB
Go

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", artwork.RecheckKinds)
Expect(err).ToNot(HaveOccurred())
Expect(k).To(Equal(model.KindArtistArtwork))
})
It("rejects an unknown kind and lists the valid ones", func() {
_, err := parseArtworkKind("zz", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ar"))
Expect(err.Error()).To(ContainSubstring("al"))
})
It("rejects a known kind the command does not accept", func() {
_, err := parseArtworkKind("mf", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
})
DescribeTable("accepts the kinds each command supports",
func(prefix string, valid []model.Kind) {
_, err := parseArtworkKind(prefix, valid)
Expect(err).ToNot(HaveOccurred())
},
Entry("explain reads disc artwork", "dc", explainKinds),
Entry("explain reads media file artwork", "mf", explainKinds),
// Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not
// accept it: the queue row would be rejected on every drain.
Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds),
)
It("rejects disc artwork for refresh", func() {
_, err := parseArtworkKind("dc", artwork.RefreshableKinds)
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 a local candidate exists but could not be read", func() {
steps := []artwork.TraceStep{
{Candidate: "cover.*", Outcome: "miss"},
{Candidate: "embedded", Outcome: "unreadable"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
})
It("does not qualify a hit that an earlier unreadable candidate preceded", func() {
// chainState.try stamps only the external error onto a hit and drops the local one, so the
// worker settles this as found; warning about it would be a false alarm.
steps := []artwork.TraceStep{
{Candidate: "embedded", Outcome: "unreadable"},
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
}
Expect(explainResult("folder", steps)).To(Equal("resolved from folder"))
})
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")
})
It("qualifies a win a failed higher-priority external lookup could have taken", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
}
res := explainResult("artist.*", steps)
Expect(res).To(ContainSubstring("resolved from artist.*"))
Expect(res).To(ContainSubstring("indeterminate"),
"the resolver serves this hit but retries later, so the winner is provisional")
})
It("does not qualify an external win that followed a failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"},
}
Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"),
"a later agent supplying the image discards the earlier error, so there is no retry to warn about")
})
It("does not qualify a win that outranked the failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "hit"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
}
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
})
})
var _ = Describe("explainAgents", func() {
It("accounts for every configured agent, marking the ones the CLI could not use", func() {
out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"})
for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} {
Expect(out).To(ContainSubstring(name),
"a configured agent missing from this line reads as if it had never been configured")
}
Expect(out).To(ContainSubstring("not available to the CLI"))
})
It("does not mark anything when every configured agent is available", func() {
out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"})
Expect(out).To(Equal("deezer, lastfm"))
})
It("reports an empty configuration as none, not as an unavailable agent", func() {
Expect(explainAgents("", nil)).To(Equal("(none)"))
})
})
var _ = Describe("formatExplain", func() {
var rep explainReport
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external, artist.*"
rep = explainReport{
kind: model.KindArtistArtwork,
id: "ar-1",
name: "Radiohead",
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() {
conf.Server.CoverArtPriority = "cover.*, embedded"
rep.kind = model.KindPlaylistArtwork
rep.steps = nil
rep.agents = ""
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")
})
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
conf.Server.DiscArtPriority = "cover.jpg, embedded"
rep = explainReport{
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
source: "folder",
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("never recorded"))
Expect(out).To(ContainSubstring("never queued"))
Expect(out).ToNot(ContainSubstring("no artwork state recorded"),
"a missing row would read as a lookup that failed, when disc artwork has no row by design")
Expect(out).To(ContainSubstring("DiscArtPriority"))
Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent")
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
steps: []artwork.TraceStep{
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
},
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("EnableMediaFileCoverArt"))
Expect(out).To(ContainSubstring("false"))
Expect(out).To(ContainSubstring("not resolved"))
Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state")
})
})
var _ = Describe("explainConfig", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DiscArtPriority = "cover.jpg"
conf.Server.EnableMediaFileCoverArt = true
})
DescribeTable("names the setting that decides where a kind's artwork comes from",
func(kind model.Kind, setting, value string) {
gotSetting, gotValue := explainConfig(kind)
Expect(gotSetting).To(Equal(setting))
Expect(gotValue).To(Equal(value))
},
Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"),
Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"),
Entry("playlist has none", model.KindPlaylistArtwork, "", ""),
)
})
var _ = Describe("discArtworkName", func() {
var ds *tests.MockDataStore
BeforeEach(func() {
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
})
It("names the album, the disc and its subtitle", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 2): Side Three"))
})
It("omits the subtitle when the disc has none", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 1)"))
})
It("rejects an id that is not <albumID>:<disc>", func() {
_, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1")
Expect(err).To(HaveOccurred())
})
})
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())
// The count includes plugin agents once they load, so the caveat is about a failed load,
// not about plugins being invisible to the CLI.
Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load"))
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 the absent filter with nothing absent, still rejecting a typo", func() {
put(model.KindArtistArtwork, "ar-2", "folder")
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}),
imageAgents, false, accept, &out)).To(Succeed(),
"a reserved source must stay valid once the library has none of it")
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(queue.Count()).To(BeZero())
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
})
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")
})
})
var _ = Describe("needsImageAgents", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("is false for a selection no agent can serve", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse())
})
// The generated playlist grid resolves album art through the image agents, so a playlist
// selection needs the count even though no agent is asked for a playlist image directly.
It("is true for playlists, whose grid tiles resolve through the album chain", func() {
Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue())
})
It("is true when any one of several kinds can reach an agent", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue())
})
It("is false once the chains no longer reach an agent", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse())
})
})
var _ = Describe("configuredAgents", func() {
BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) })
It("splits and trims the configured list", func() {
conf.Server.Agents = "lastfm, spotify ,deezer"
Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"}))
})
// An empty name would match no plugin, but it also must not make the list look non-empty:
// LoadPlugins treats an empty list as "load nothing".
It("drops empty entries rather than passing a name nothing can match", func() {
conf.Server.Agents = " , ,"
Expect(configuredAgents()).To(BeEmpty())
})
})