2 Commits

Author SHA1 Message Date
Deluan Quintão
c26f6f9e98
feat(artwork): store the resolution trace so artwork explain works offline (#5980)
* feat(artwork): record the resolution trace so explain works without --live

The worker never attached a ChainTrace, so `artwork explain` had to re-walk the
priority chain at CLI time. That reconstruction could disagree with what actually
happened, and without --live it could not report the external tier at all.

The worker now traces every acquisition and stores it. `explain` reads the stored
trace by default and reports when it was recorded; --live re-walks and calls the
agents. Disc artwork keeps no row, so it always walks live.

A chain trace alone would have explained almost nothing about failures: six of the
seven ways an item can fail happen after the chain has already picked a winner. The
trace now covers those stages too, and has somewhere to live when they fail: the
retrying queue row carries the last failure, and the state row keeps it in
last_failure once the retry budget is spent and the queue row is deleted.

Measured on a copy of a 682MB / 43.6k-item library: +9.7MB (+1.4%). No row crosses
the WITHOUT ROWID overflow threshold, so list hydration is unchanged; only full
scans of item_artwork, which no request performs, read more pages.

* test(artwork): pin the give-up ordering that keeps a failure for unresolved items

recordGiveUp updates an existing row, and for a kind with a recheck path that row is
only created moments earlier by the absent settle. Recording before the settle would
lose the failure for every item that never resolved, with nothing to catch it.

* refactor(artwork): tighten the trace code after review

Four fixes worth taking:

The doc comments on ChainTrace and chainState.trace still said the worker never
attaches a trace and resolution stays allocation-free — the exact invariant this
branch reverses.

explain's report field meant both "the chain shown was walked just now" and "go out
for real", and was being passed to loadPluginAgents, which --live documents as the
only thing that may open external connections. Renamed to `walked` and restored
explainLive as the sole input to that decision.

A stored Detail is an error string on the failure paths, with no bound. The measured
"no row reaches the WITHOUT ROWID overflow limit" only holds while it is bounded, so
cap it at 200 runes.

offlineGate was a factory returning a constant closure; make it a plain gateFunc like
its sibling passthroughGate. Collapse five copies of the age-a-queue-row loop in the
worker tests into one helper.

* refactor(artwork): drop the offline explain walk, now that traces are stored

`artwork explain` reported the external tier without calling it, so a diagnostic
could not add load to a provider already rate-limiting us. Reading the stored trace
answers that better: it reports what the agents actually returned, not what would
be tried.

Nothing could reach the offline gate any more. It was installed only for a walk
with --live unset, which now happens for disc artwork alone, and disc rejects the
external candidate before any gate call. That made the gate, its sentinel error,
the would-try outcome and two of explain's verdicts unreachable.

Removes offlineGate, errOfflineSkipped, OutcomeWouldTry, the NewTracingResolver
live parameter and the CreateArtworkResolver argument threaded through wire.

Verified against a copy of a real library: disc artwork with "external" first in
DiscArtPriority and external services enabled still records the skip and issues no
agent call.

* fix(artwork): make explain's no-network guarantee structural, not incidental

Serving falls back disc -> album and track -> disc -> album. The resolver layer
explain uses has no such fallback today, so dropping the offline gate did not leak.
But the guarantee rested on which chains happen to lack an external tier, and the
serving layer already shows the fallback shape someone could mirror.

Without --live the tracing resolver is now built with no agents at all, so no chain
and no fallback added later can reach a provider. That is stronger than the gate it
replaces, which only intercepted the call.

The test pins it against exactly that regression: with the guard removed and the
serving fallback mirrored into resolveDisc, it fails.

* refactor(artwork): trim the trace plumbing

EncodeTrace was exported for nobody: only this package writes traces, and cmd reads
them. It becomes a ChainTrace method, which also drops the copy Steps made for a
caller that only wanted to serialize.

explain's report carried queuedSteps and failureSteps, both pure functions of the
queue and state rows already in the struct, which let a test set the two out of step
with each other. formatExplain derives them, as it already does for every other
display value.

The trace row format and its tabwriter empty-cell rule lived in two places, and the
"nothing was ever recorded" predicate in three.

* fix(artwork): clear the queue trace on a fresh re-enqueue

Enqueue's conflict clause reset attempts to 0 but left the new trace
column, so after a scan or refresh re-enqueued a previously-failed item
artwork explain showed "Attempts: 0" next to the prior lifecycle's
"Last attempt failed" trace. Clear trace in Enqueue (a fresh lifecycle
has no last attempt); EnqueuePreservingBackoff still keeps it.

* fix(artwork): treat a processing-stage error as indeterminate in explain

A read/hash/decode/store failure records an OutcomeError step and writes an
absent row, but explainResult only mapped external errors and unreadable
candidates to indeterminate, so the default verdict read "not resolved" —
presenting a processing failure as a definitive miss. The worker retries
these exactly as it retries an unreadable candidate, so classify any
OutcomeError as indeterminate too.

* fix(artwork): record a trace step when a chainless resolver faults

Playlist and radio resolvers walk no priority chain, so a fault (unreadable
upload/sidecar, or an m3u fetch error with no grid) returned localError/extError
without recording any trace step. The attempt then encoded [], leaving artwork
explain with an empty "Last attempt failed" and "Gave up after". Record a
fallback step in the faulted-no-image branch when nothing else did, and carry
the source label through resolveLocalFile so the step can name it.

* fix(artwork): trace the m3u failure at its source, not via the empty guard

A playlist's grid sampling records album-chain steps into the shared trace, so
the processor's empty-trace fallback no longer fires when the m3u remote image
fetch failed — the error that forced the retry was omitted from explain. Record
it where it happens, in resolvePlaylist's external step, as external:m3u.

* test(artwork): skip the chainless-fault spec on Windows

The spec provokes an open fault with a non-directory parent, but Windows maps
that to a not-exist error, so localError is never set and the item resolves
absent instead of failed. The sibling failed-on-unreadable-upload spec skips
Windows for the same class of reason.

* fix(artwork): don't label an absent empty-chain row as pre-tracing

explain reported "resolved before traces were recorded" for any stored row
with an empty chain, but an empty CoverArtPriority records a real, empty [] chain
and resolves absent. A recorded resolution that finds an image always records its
winning candidate, so only a row with a hash and no chain predates tracing; split
on the hash and report an absent empty chain plainly instead.

* fix(db): retimestamp the artwork trace migration after rebase

master merged a 2026-08-18 migration, so the original 2026-08-16 timestamp is now
older than the newest on the base branch and Goose would silently skip it on an
already-upgraded database. Bumped past it; the SQL is unchanged.

* fix(artwork): keep the m3u error detail in the trace

The m3u trace step recorded OutcomeError with no detail because resolveExternalStep
collapsed the gate's error to a bool, so explain showed only "external:m3u error -"
and could not tell a timeout from an HTTP error or an open breaker. Return the error
(normalizing not-found to nil so it stays a definitive miss, not a failure) and store
its message as the step detail; encodeSteps already bounds it.

* docs(artwork): note the give-up write relies on serial draining

recordGiveUp writes last_failure unconditionally; that is only correct because
the drain resolves each item serially, so no concurrent success can store artwork
between the write and the queue delete. Record the invariant at the call site.
2026-08-21 10:24:01 -04:00
Deluan Quintão
dc40bcaf80
feat(cli): add an artwork command group for diagnosing and re-driving artwork (#5957)
* feat(artwork): add a resolution chain trace collector

* feat(artwork): trace the local priority chain

* fix(artwork): record priority candidates the chain never evaluated

* refactor(artwork): report never-evaluated candidates as skipped

* feat(artwork): trace external agents at the gate seam

* feat(artwork): add repository queries to enqueue by current source

* feat(artwork): expose a tracing resolver for the CLI

* feat(artwork): read a single queue row by item

The explain CLI must report whether an item is queued, at what priority and when it
retries; the queue repository could only be drained in eligibility batches, which
cannot see a row that is still backing off.

* feat(cli): add artwork explain

Prints why an item has the artwork it has: the stored state, its queue row, the
governing config, the resolver's priority-chain walk and the verdict. Offline by
default so a diagnostic run cannot add load to an external provider; --live asks
the agents for real. Playlists and radios do not walk a priority chain, so they
report that instead of an empty chain table.

* fix(artwork): trace an external tier that never reaches an agent

A configured 'external' token vanished from the chain when no enabled agent provided
images for that entity type, and for synthetic artists, leaving the trace unable to
say whether the tier was even considered.

* fix(cli): never state an artwork outcome the walk did not observe

A transient external failure traced as 'error' fell through to 'not resolved', which
is the most common state behind a missing-artwork report. It is now indeterminate, and
an offline win that a skipped higher-priority external candidate could have taken says
so instead of naming a winner the live chain might not pick.

* feat(cli): add artwork refresh

* feat(cli): add artwork reprocess

Bulk re-enqueues artwork by kind and/or by the source an item currently
resolves from, previewing the matched count and confirming before queueing.

The preview counts with CountBySource (rows matched) and reports separately
what EnqueueBySource inserted: its DO NOTHING conflict policy leaves an
already-queued row untouched, so the two numbers differ and the output must
not claim the skipped rows were re-queued.

An unknown --source is rejected against the sources present in item_artwork,
rather than silently matching nothing and printing a reassuring 0.

* fix(cli): cover the reprocess selection rule and validate sources table-wide

The reconciliation that makes --source alone target every kind was only
exercised through runReprocess, which no test calls: mutating it to
`all := reprocessAll` left the suite green. It is now reprocessSelectsAll,
covered for all three selectors.

Scoping source validation to the selected kinds made the same well-formed
filter valid or invalid depending on which other kinds were selected, and its
error read the same for a typo as for a source that simply does not apply to
the chosen kind. Validation is now table-wide: a typo still aborts, while a
valid-but-inapplicable source falls through to "Nothing matches".

Also: the prompt now counts only the kinds that reach an external agent as
external cost, and --dry-run on an empty selection reports a dry run.

* fix(cli): cover the reprocess --yes guard and preview the external cost

Mutating the --yes check to `if true` left the suite green, so the one bypass
of the confirmation was unverified. The choice is now reprocessConfirm(yes, in),
covered in both directions.

The external estimate only reached the operator through the prompt, which
--dry-run skips — hiding the number in the one mode that exists to show it
before committing. The preview now carries it, and the prompt drops the clause
when no lookup will be made.

An empty selection says so again under --dry-run.

* feat(artwork): add read-only queue and absent counters

Both are needed by the artwork status CLI: a queue breakdown by kind and priority, and
the absent totals split against the recheck cutoff.

* feat(cli): add artwork status

Reports the queue, where artwork currently resolves from, absent counts against the 24h
recheck window, and the stored config fingerprint versus the current one — the line that
turns 'why is my server re-resolving everything?' into one command.

fingerprint() and staleAbsentAge are exported so the CLI reports the values backfill
itself compares, instead of a second copy of the formula that can silently drift.

* fix(cli): lead the artwork status backfill line with the queued backlog

By the time anyone runs a diagnostic, backfill has usually already stored the new
fingerprint, so 'up to date' was printed while thousands of items churned through external
providers. The backlog is the finding; the fingerprint is context.

Also echoes the config inputs the fingerprint covers, so a change can be traced to the
setting that caused it, and pins the rendered rows: the Absent values, the queue TOTAL and
a queue-scoped kind/priority pair were all unasserted, so kindName and priorityName were
effectively untested. FingerprintInputs is now the single listing ConfigFingerprint hashes;
a pinned hash proves the value did not change.

* refactor(artwork): export the trace outcome vocabulary

The CLI hardcoded the outcome literals and the "external:" prefix, so renaming a
constant's value in core/artwork left cmd compiling and the suite green while
`artwork explain` silently degraded its verdict.

Renaming a value now fails the golden vocabulary test in core/artwork and the
explainResult tests in cmd.

* fix(cli): keep the re-enqueue warning when a backfill is already running

A stale stored fingerprint with items already queued is the worst state the
system can be in: a second full re-enqueue is pending on top of the one running.
The line carried the weakest wording of the three, and was untested.

* refactor(artwork): drop the unreachable breaker branch from the tracing gate

--live wires the tracing gate straight to passthroughGate, so errBreakerOpen can
never reach it; the test only passed by injecting a fake gate.

* refactor(artwork): delete the never-emitted not-reached outcome

Candidates after the winner are lower priority and say nothing about why a source
won; the ones that matter sit above it and are already recorded.

* refactor(artwork): make the trace nil-safe in one place only

add already handles a nil trace, so record's own guard was dead; Steps was the
odd one out and would panic where every other method tolerates nil.

* refactor(artwork): export the trace types directly

ChainTrace and TraceStep were unexported types re-exported through aliases,
which existed only so the CLI had a name to refer to them by. The types are
public API — Resolver.Steps returns []TraceStep and the CLI constructs a
ChainTrace — so name them that way and drop the indirection.

Encapsulation is unchanged: add, mu and steps stay unexported, so only this
package can write a step.

* refactor(cli): simplify parseArtworkKind with slices.Contains

Replaces a nested loop and a manual append with slices.Contains and the
repo's slice.Map helper. Same behaviour, same error message.

* fix(cli): print the absent artwork source under the name --source accepts

`artwork explain` rendered the stored empty source as "(absent)", while
`artwork reprocess --source` only accepts "absent", so pasting what explain
printed straight back into reprocess was rejected as an unknown source.

* refactor(artwork): own the kind list and the chain predicate in the package

Export RecheckKinds and add WalksPriorityChain so the CLI stops keeping its
own copies of both, and unexport externalCandidate, which nothing outside the
package consumes.

* refactor(cli): drop the artwork command's duplicated state and formatting

Reuse artwork.RecheckKinds and artwork.WalksPriorityChain, extract
newTabWriter and externalEstimate, fold reprocessSelectsAll into
selectedKinds, and derive the queue total and the walks-chain flag instead of
carrying them in the report structs.

* test(persistence): drop two artwork-queue specs that cannot fail

One seeded hash and source together and then asserted the two counts agree,
so its setup guaranteed the result; the other repeated the count-does-not-
enqueue property already covered by the CountBySource spec.

* refactor(artwork): rename Resolver to TracingResolver for clarity

* fix(cli): count playlists in the artwork reprocess external estimate

The estimate used WalksPriorityChain, which is true only for artist and album,
so a playlist-only reprocess reported "External lookups: none" and the
confirmation prompt dropped the external-cost warning. Playlists do reach the
network: through the m3u ExternalImageURL fetch when EnableM3UExternalAlbumArt
is on, and — verified by test — through the generated grid, whose tiles resolve
album art via the full album priority chain.

Adds artwork.MayFetchExternal, a config-aware predicate for "can this kind's
resolver reach the network", and uses it for the estimate. WalksPriorityChain
keeps its separate job of deciding whether explain prints a chain block.

* fix(cli): estimate artwork reprocess external lookups per agent, not per item

The reprocess prompt billed one external lookup per externally-capable item.
fetchArtistImage/fetchAlbumImage try every enabled image agent and stop early
only on a hit, and resolvePlaylist can fetch the m3u image and then resolve up
to four sampled albums for the grid, each walking the album agents again. The
number the operator confirmed could understate real provider traffic several
fold, in the prompt whose whole job is to stop a provider flood.

ExternalLookupsPerItem now multiplies by the visible image-agent count and adds
the playlist grid factor. It stays a floor: the CLI never calls Manager.Start(),
so the plugin registry is empty and plugin-provided agents are dropped by
getEnabledAgentNames. On an install with 5 agents of which 3 are plugins the
count is well under the truth, so the wording is now "at least N" rather than
"up to N" — a zero visible count still bills one lookup for the same reason.

Fixing the plugin visibility is out of scope: Manager.Start() needs a Subsonic
router and writes to the DB via syncPlugins, breaking this command group's
read-only guarantee.

* fix(cli): state the artwork reprocess estimate as an estimate, not a bound

Neither bound is true. A ceiling is false because plugin agents are invisible to
a CLI that never starts the plugin manager, and a floor is false because a local
hit ends the walk before any agent is asked and a hit on the first agent skips
the rest. "at least N" traded one wrong claim for another.

The line now names its blind spots instead:

  External lookups: ~340 estimated (plugin agents not counted; local hits may
  need fewer).

The same line is reused in the confirmation prompt, and the zero case still
reads "External lookups: none." with the prompt dropping the clause entirely.
The count itself is unchanged.

* fix(cli): account for every configured agent in artwork explain

The Agents: line printed the raw config while the Chain only showed the agents the CLI could
construct, with nothing explaining the gap: plugin agents are never registered in a CLI that does
not start the plugin manager, and a built-in without credentials returns nil. Three of five agents
could vanish, including ones ranked above the one shown.

Also treat a live external error before the winning hit like the already-handled would-try case:
the resolver serves such a hit provisionally and retries later, so the verdict is indeterminate.

The Result line is still not qualified when an unavailable agent might have won; that needs agent
ranking, and is left to the follow-up that makes the CLI load plugin agents for real.

* fix(cli): do not call an external artwork win indeterminate

explainResult qualified the verdict whenever an external OutcomeError
appeared before the winning hit. When a later external agent returns an
image, fetchArtistImage/fetchAlbumImage discard the earlier error, so
extError is false: the worker settles the item and schedules no retry.
Telling the operator it may resolve differently on a retry was wrong.

The warning is only correct when a lower-priority local source won while
an external error was recorded, which is the case that carries extError.

* fix(cli): accept --source absent when nothing is currently absent

validateSources checks the requested sources against the ones item_artwork
actually uses, to catch a typo. The reserved empty source (spelled 'absent' on
the CLI) is a valid filter even when it matches nothing, so a scheduled
'artwork reprocess --source absent --yes' stopped working the moment the
library finished resolving. Treat it as intrinsically valid and let the
existing zero-match path report it.

* feat(artwork): explain disc and media file artwork from the CLI

`artwork explain` rejected `dc` and `mf` because it validated against RecheckKinds,
the list of kinds the backfill revisits. Those are different questions: a kind with no
recheck path still has artwork someone can report as wrong.

Disc artwork now walks DiscArtPriority under a trace, so explain reports which entry won
and why the others lost, including entries that map to no source at all (external is
unsupported, a disc with no subtitle, an album folder with no images). Media file artwork
traces its single embedded candidate, separating "EnableMediaFileCoverArt is off" from
"the track has no embedded art" — stored state cannot tell those apart.

Each command now validates against the kinds it can actually serve: explain takes all six,
refresh takes artwork.RefreshableKinds (which nativeapi now shares instead of keeping its
own copy), reprocess still takes RecheckKinds. Disc artwork stays out of refresh: the
worker cannot resolve it, so the queue row would be rejected on every drain.

WalksPriorityChain becomes Explainable, and ResolveArtist/ResolveAlbum collapse into
Resolve(kind, id).

* refactor(artwork): one disc-artwork walk for serving and explain

resolveDisc duplicated the loop selectImageReader already ran: try each source in
priority order, take the first that yields an image. The serving path and the CLI
diverged on two details as a result — only selectImageReader checked ctx between
candidates and logged each attempt.

Both now call discArtworkReader.selectImage, which takes the chainState the CLI already
uses for the other kinds. The serving path passes an untraced one, whose nil trace makes
recording a no-op. selectImageReader had no other caller and is gone.

The disc tests move from fromDiscArtPriority to discCandidates, so they assert the skip
reason for an entry that maps to no source rather than that it silently vanished, and
cancellation mid-walk is now covered.

* fix(artwork): reject a nil reader in the resize cache instead of panicking

resizedItem.Reader closes what open() hands back, so an open() that reports "no image"
as (nil, nil) rather than an error takes the request down with a nil-pointer panic. Every
caller returns an error today, and no test covered it: the resolution e2e harness stubs
the resize reader out entirely, so no e2e path reaches this code at all.

Guard it and cover Reader directly.

* refactor(artwork): move the keeps-state fact into core, drop a redundant guard

keepsArtworkState lived in package cmd and re-derived by hand what RefreshableKinds
already encodes: the same five-of-six kinds. It is now artwork.KeepsState, beside the
list, with a test pinning the two together — nothing else stopped them drifting, and a
drift would have explain report stored state for a kind that keeps none.

serveDisc's closure also hand-rolled a nil-reader error that both consumers of open()
now produce themselves: serveSource for a full-size request, resizedItem.Reader for a
resized one.

* fix(artwork): route disc candidates through the shared resolvers

openCandidate ran its own source loop and threw the error away, so a disc track that
exists but cannot be parsed traced as "miss" — indistinguishable from a track with no
embedded art. fromTag and fromFFmpegTag already report that case as errSourceUnreadable;
only this loop was discarding it. Telling those two apart is what the trace is for.

Candidates now carry a resolve func instead of raw sources: embedded goes to
resolveEmbedded, and the folder-backed entries to resolveFolderSource, extracted from
resolveFolderFile so both callers classify an unopenable file the same way. openCandidate
and its absolute-path special case go away with it.

Disc's own fromExternalFile and fromDiscSubtitle still swallow open errors, so folder
candidates cannot report unreadable yet; that is a change to their error contracts.

* fix(artwork): report an unreadable local candidate as indeterminate

processor.acquire treats resolution.localError exactly as it treats extError: a fault is
not a definitive "no image", so it retries instead of settling absent. explainResult
qualified only the external case, so a chain that ended on an unreadable local candidate
printed "not resolved" — the one verdict that says the walk was conclusive.

The qualification belongs only to the unresolved branch. chainState.try stamps extErr onto
a hit and deliberately drops localErr, so an unreadable step followed by a hit is settled
as found and must not carry a warning; a test pins that.

Found by Codex on 5f65d7cfa.
2026-08-14 21:07:56 -04:00