feat(cli): add artwork cancel to call off queued artwork work (#6006)

* feat(cli): add `artwork cancel` to call off queued artwork work

A bulk backfill had no off switch. Changing an artwork setting bumps the config
fingerprint, which enqueues every entity in the library, and the only way to stop
it was to turn agents off -- which changes the fingerprint again and enqueues a
second full backfill. The escape hatch was the trap.

`artwork cancel` deletes pending queue rows selected by --kind and/or --priority,
with the --dry-run/confirm/-y flow `reprocess` already uses. Cancelling by
priority is the point: it drops a runaway backfill while leaving the bump-priority
rows an operator queued by hand.

It only touches the queue. Resolved artwork and the item_artwork state behind
`artwork explain` are left alone, and the trace of why a cancelled item last
failed goes with its row. Preserving that trace would mean writing it to
last_failure, which `explain` prints under "Gave up after" -- reporting a
cancellation as an exhausted retry budget. The help text says the trace is
discarded instead.

Two limits the help text states, because neither is guessable: work already
dequeued is not interrupted, and an item with no artwork state yet can be queued
again by the hourly missing-artwork recheck. Cancel calls off queued work; it
does not stop the worker.

--kind validates against RefreshableKinds, not the RecheckKinds `reprocess` uses:
the queue holds media file rows, so --all has to reach them. PurgeQueued follows
the repository's naming rule -- it finds its own rows and reports how many went --
and ignores retry_at, since a row still backing off is pending work. The preview
reuses CountByKindAndPriority rather than adding a counter. reprocessConfirm
became confirmUnlessYes(yes, in, verb) now that two commands prompt.

* refactor(cli): share the artwork queue filter between the preview and the delete

Follow-up cleanup on the previous commit; no change to what the command does,
apart from --all, noted below.

The "which rows does cancel touch" predicate was written three times: once as SQL
in PurgeQueued, once in Go in cmd's matchingQueueStats, and once more in the mock.
The preview and the delete could therefore drift, and the mock would keep the
tests green while they did. persistence now has one artworkQueueFilter, shared by
PurgeQueued and a new CountQueued, and cmd does no filtering at all.

That also makes the preview cheaper. It counted the whole queue and filtered in
Go, so `artwork cancel --kind al` scanned every row of every kind to print a
handful. CountQueued pushes the filter into SQL, which the drain index serves as a
range seek. CountByKindAndPriority is gone: it is CountQueued(nil, nil).

--all now selects with an empty filter instead of enumerating RefreshableKinds.
It is what the flag help already claimed, and the enumeration was narrower than
its own documentation -- a queue row whose item_kind this build does not know
survived `--all` with no flag combination able to remove it. It also restores
SQLite's truncate path: measured with EXPLAIN QUERY PLAN, a bare DELETE plans to
nothing, while `WHERE (1=1)` -- which an empty squirrel And renders -- plans to a
full index scan. A test pins the filter's emptiness so that cannot regress
silently.

Also folded together three copies of the parse-and-dedup loop (parseAll), two
copies of the queue-stats table (printQueueStats, now shared with `artwork
status`), two copies of the stat sum (queueTotal), and four copies of the
kind-to-prefix mapping (model.KindPrefixes). The PurgeQueued specs became one
DescribeTable that asserts count and delete agree on every selection.

* docs(cli): say when `artwork cancel` evaluates its selection

The help text covered the two limits that surprise an operator after the fact, but
not the one that bites during the prompt: the count is a preview, and the filters
run again on confirm. A scan or a manual refresh landing in between is cancelled
without ever appearing in the table the operator agreed to.

Deleting only the previewed rows was considered and rejected. The exposure is one
item re-resolving on next view instead of immediately: clearing an item's artwork
state is what every recovery path selects on, so a lost Bump row from
artwork.Refresh comes back at the same priority via provisional() on the next
request, and otherwise within the hour via EnqueueAllMissing. Buying a guarantee
against that costs the truncate path on --all, the flag that exists for a
29k-item backfill.

* refactor(cli): share one set of flag targets across the artwork subcommands

reprocess and cancel each declared their own kinds/all/dry-run/yes variables, but
cobra only ever parses the one subcommand being run, so the two sets could never
hold values at the same time. backup.go already binds one backupDir across two
subcommands and one force across two more; this follows that.

Ten package-level variables become six. Each command keeps its own help string
and its own valid-kind list, so --kind still reports RecheckKinds for reprocess
and RefreshableKinds for cancel, and --source and --priority stay registered only
on the command that has them.

The priority lookup table is now knownPriorities, freeing the artworkPriorities
name for the flag. The new name also reads better against priorityName's fallback
for a value it does not know.
This commit is contained in:
Deluan Quintão 2026-08-21 14:03:59 -04:00 committed by GitHub
parent c26f6f9e98
commit ffc68e29db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 479 additions and 66 deletions

View File

@ -26,12 +26,14 @@ import (
var explainLive bool
// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets.
var (
reprocessKinds []string
reprocessSources []string
reprocessAll bool
reprocessDryRun bool
reprocessYes bool
artworkKinds []string
artworkSources []string
artworkPriorities []string
artworkAll bool
artworkDryRun bool
artworkYes bool
)
func init() {
@ -39,17 +41,26 @@ func init() {
"walk the chain again now, performing real external lookups, instead of reporting the "+
"stored trace of the last resolution; also initializes plugin agents, which may open "+
"external connections")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil,
artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil,
artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil,
"only items currently resolved from these sources (e.g. folder, external:deezer, absent)")
artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false,
artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
"report what would be queued and exit without queueing")
artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt")
artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
"kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable")
artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil,
"only rows queued at these priorities ("+priorityNames()+"); repeatable")
artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority")
artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
"report what would be cancelled and exit without cancelling")
artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
artworkCmd.AddCommand(artworkExplainCmd)
artworkCmd.AddCommand(artworkRefreshCmd)
artworkCmd.AddCommand(artworkReprocessCmd)
artworkCmd.AddCommand(artworkCancelCmd)
artworkCmd.AddCommand(artworkStatusCmd)
rootCmd.AddCommand(artworkCmd)
}
@ -93,6 +104,22 @@ var artworkReprocessCmd = &cobra.Command{
},
}
var artworkCancelCmd = &cobra.Command{
Use: "cancel",
Short: "Cancel pending artwork work in bulk, by kind and/or queue priority",
Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" +
"Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" +
"left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" +
"Work already picked up is not interrupted, and an item with no artwork yet can be\n" +
"queued again by the hourly re-check. The selection is applied again when you confirm,\n" +
"so anything queued after the preview is cancelled too. Use it to call off a bulk\n" +
"backfill, not to stop the worker.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runCancel(cmd.Context())
},
}
var artworkStatusCmd = &cobra.Command{
Use: "status",
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
@ -133,9 +160,11 @@ type statusReport struct {
current string
}
func (r statusReport) queueTotal() int64 {
func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) }
func queueTotal(stats []model.ArtworkQueueStat) int64 {
var n int64
for _, s := range r.queue {
for _, s := range stats {
n += s.Count
}
return n
@ -155,7 +184,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
q := ds.ArtworkQueue(ctx)
var rep statusReport
var err error
if rep.queue, err = q.CountByKindAndPriority(); err != nil {
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
@ -195,11 +224,7 @@ func formatStatus(rep statusReport) string {
if len(rep.queue) == 0 {
fmt.Fprintln(w, " (empty)")
} else {
fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS")
for _, s := range rep.queue {
fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal())
printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ")
}
fmt.Fprintln(w, "\nSources")
@ -246,6 +271,15 @@ func backfillState(rep statusReport) string {
return "up to date"
}
// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it.
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
for _, s := range stats {
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total)
}
func kindName(prefix string) string {
if k, ok := model.ParseKind(prefix); ok {
return k.String()
@ -253,22 +287,44 @@ func kindName(prefix string) string {
return prefix
}
type artworkPriority struct {
name string
value int
}
// knownPriorities is the one listing behind both the name and the parse, so they cannot drift.
var knownPriorities = []artworkPriority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"backfill", model.ArtworkPriorityBackfill},
{"recheck", model.ArtworkPriorityRecheck},
}
// priorityName falls back to the number: a row written by a newer version still has to print.
func priorityName(p int) string {
switch p {
case model.ArtworkPriorityRecheck:
return "recheck"
case model.ArtworkPriorityBackfill:
return "backfill"
case model.ArtworkPriorityScan:
return "scan"
case model.ArtworkPriorityBump:
return "bump"
for _, ap := range knownPriorities {
if ap.value == p {
return ap.name
}
}
return strconv.Itoa(p)
}
func priorityNames() string {
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
}
func parseArtworkPriority(s string) (int, error) {
for _, ap := range knownPriorities {
if ap.name == s {
return ap.value, nil
}
}
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
}
func runReprocess(ctx context.Context) {
kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll)
kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll)
if err != nil {
log.Fatal(ctx, err)
}
@ -285,8 +341,8 @@ func runReprocess(ctx context.Context) {
imageAgents = imageAgentCount(ds, mgr)
}
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents,
reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil {
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents,
artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
@ -299,16 +355,9 @@ func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
if len(kinds) == 0 {
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
}
out := make([]model.Kind, 0, len(kinds))
for _, k := range kinds {
kind, err := parseArtworkKind(k, artwork.RecheckKinds)
if err != nil {
return nil, err
}
out = append(out, kind)
}
// A repeated kind would be counted twice, overstating the cost the operator confirms.
return slice.Unique(out), nil
return parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RecheckKinds)
})
}
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
@ -327,11 +376,11 @@ func displaySource(s string) string { return cmp.Or(s, absentSource) }
type confirmFunc func(out io.Writer, total, external int64) bool
func reprocessConfirm(yes bool, in io.Reader) confirmFunc {
func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc {
if yes {
return func(io.Writer, int64, int64) bool { return true }
}
return promptConfirm(in)
return promptConfirm(in, verb)
}
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
@ -379,13 +428,13 @@ func configuredAgents() []string {
return names
}
func promptConfirm(in io.Reader) confirmFunc {
func promptConfirm(in io.Reader, verb string) confirmFunc {
return func(out io.Writer, total, external int64) bool {
var cost string
if external > 0 {
cost = fmt.Sprintf(" %s", externalLookupLine(external))
}
fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost)
fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost)
var answer string
if _, err := fmt.Fscanln(in, &answer); err != nil {
return false
@ -477,6 +526,90 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
return nil
}
func runCancel(ctx context.Context) {
kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll)
if err != nil {
log.Fatal(ctx, err)
}
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun,
confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row
// whose kind this build does not know still gets cancelled.
func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) {
if all {
return nil, nil, nil
}
if len(kinds) == 0 && len(priorities) == 0 {
return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all")
}
// RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them.
outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) {
return parseArtworkKind(s, artwork.RefreshableKinds)
})
if err != nil {
return nil, nil, err
}
outPriorities, err := parseAll(priorities, parseArtworkPriority)
if err != nil {
return nil, nil, err
}
return outKinds, outPriorities, nil
}
// parseAll drops repeats: a doubled selector would overstate the total the operator confirms.
func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) {
out := make([]T, 0, len(values))
for _, v := range values {
parsed, err := parse(v)
if err != nil {
return nil, err
}
out = append(out, parsed)
}
return slice.Unique(out), nil
}
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
matched, err := q.CountQueued(kinds, priorities)
if err != nil {
return fmt.Errorf("counting queued artwork: %w", err)
}
total := queueTotal(matched)
w := newTabWriter(out)
printQueueStats(w, matched, total, "MATCHED", "")
w.Flush()
switch {
case total == 0:
fmt.Fprintln(out, "\nNothing matches this selection.")
return nil
case dryRun:
fmt.Fprintln(out, "\nDry run: nothing was cancelled.")
return nil
case !confirm(out, total, 0):
fmt.Fprintln(out, "Aborted: nothing was cancelled.")
return nil
}
cancelled, err := q.PurgeQueued(kinds, priorities)
if err != nil {
return fmt.Errorf("cancelling queued artwork: %w", err)
}
// Count and delete are separate statements, so a drain in between makes these two differ.
fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total)
return nil
}
// printReprocessPreview also states the external estimate, which --dry-run must show because it
// skips the prompt that would otherwise carry it.
func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) {
@ -542,7 +675,7 @@ var explainKinds = []model.Kind{
}
func kindPrefixes(kinds []model.Kind) string {
return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ")
return strings.Join(model.KindPrefixes(kinds), ", ")
}
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {

View File

@ -549,36 +549,36 @@ var _ = Describe("promptConfirm", func() {
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(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&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())
Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&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(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("cancel 3 items."))
Expect(out.String()).ToNot(ContainSubstring("External lookups"))
})
})
var _ = Describe("reprocessConfirm", func() {
var _ = Describe("confirmUnlessYes", 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(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&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(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue())
Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads")
})
})
@ -1059,3 +1059,153 @@ var _ = Describe("configuredAgents", func() {
Expect(configuredAgents()).To(BeEmpty())
})
})
var _ = Describe("parseArtworkPriority", func() {
It("accepts every name status prints", func() {
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
}
})
It("rejects an unknown name and lists the valid ones", func() {
_, err := parseArtworkPriority("urgent")
Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`)))
Expect(err).To(MatchError(ContainSubstring("backfill")))
})
// Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing.
It("rejects the numeric form", func() {
_, err := parseArtworkPriority("10")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("artwork cancel selection", func() {
It("errors when no selector is given", func() {
_, _, err := cancelSelection(nil, nil, false)
Expect(err).To(MatchError(ContainSubstring("no selector given")))
})
// Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind
// this build does not recognise.
It("selects with no filter at all for --all", func() {
kinds, priorities, err := cancelSelection(nil, nil, true)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(BeEmpty())
Expect(priorities).To(BeEmpty())
})
// The queue holds media file rows, so --all must reach them.
It("accepts media file artwork, which reprocess does not", func() {
kinds, _, err := cancelSelection([]string{"mf"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork}))
})
It("treats a priority filter on its own as a complete selection", func() {
kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(BeEmpty(), "no kind filter means every kind")
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill}))
})
It("returns only the named kinds and priorities", func() {
kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}))
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}))
})
It("counts a repeated kind and a repeated priority once", func() {
kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(kinds).To(HaveLen(1))
Expect(priorities).To(HaveLen(1))
})
It("rejects an unknown kind", func() {
_, _, err := cancelSelection([]string{"zz"}, nil, false)
Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`)))
})
It("rejects a kind that is never queued", func() {
_, _, err := cancelSelection([]string{"dc"}, nil, false)
Expect(err).To(MatchError(ContainSubstring("invalid kind")))
})
It("rejects an unknown priority", func() {
_, _, err := cancelSelection(nil, []string{"urgent"}, false)
Expect(err).To(MatchError(ContainSubstring("invalid priority")))
})
})
var _ = Describe("cancelArtwork", func() {
var ds *tests.MockDataStore
var queue *tests.MockArtworkQueueRepo
var out strings.Builder
ctx := context.Background()
accept := func(io.Writer, int64, int64) bool { return true }
decline := func(io.Writer, int64, int64) bool { return false }
BeforeEach(func() {
ds = &tests.MockDataStore{}
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
Expect(queue.Enqueue(
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBackfill},
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBump},
model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityBackfill},
)).To(Succeed())
})
It("previews the per-kind breakdown and cancels nothing on a dry run", func() {
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("artist"))
Expect(out.String()).To(ContainSubstring("backfill"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("cancels nothing when the operator declines", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("deletes the selected rows and leaves the rest queued", func() {
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeNumerically("==", 1))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
})
It("cancels every kind and priority when neither filter is given", func() {
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(BeZero())
})
It("stops at a selection that matches nothing instead of prompting", func() {
refuse := func(io.Writer, int64, int64) bool {
Fail("must not prompt when nothing matches")
return false
}
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
Expect(queue.Count()).To(BeNumerically("==", 3))
})
It("reports a queue read failure instead of reporting nothing to cancel", func() {
queue.Err = errors.New("read failed")
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed")))
})
})

View File

@ -157,13 +157,16 @@ type ArtworkQueueRepository interface {
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// CountByKindAndPriority reports the pending queue rows grouped by kind and priority.
CountByKindAndPriority() ([]ArtworkQueueStat, error)
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
// an empty filter means every one.
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
// CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent
// would pick up at the given cutoff.
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
}
type ArtworkQueueStat struct {

View File

@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/utils/slice"
)
type Kind struct {
@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{
KindRadioArtwork.prefix: KindRadioArtwork,
}
// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string.
func KindPrefixes(kinds []Kind) []string {
return slice.Map(kinds, func(k Kind) string { return k.prefix })
}
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
func ParseKind(prefix string) (Kind, bool) {

View File

@ -191,19 +191,45 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
return purgeDangling(r.sqlRepository)
}
// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps
// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too.
func artworkQueueFilter(kinds []model.Kind, priorities []int) And {
var f And
if len(kinds) > 0 {
f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)})
}
if len(priorities) > 0 {
f = append(f, Eq{"priority": priorities})
}
return f
}
// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses.
func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName).
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc")
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
sel = sel.Where(f)
}
var res []model.ArtworkQueueStat
err := r.queryAll(sel, &res)
return res, err
}
func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
del := Delete(r.tableName)
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
del = del.Where(f)
}
return r.executeSQL(del)
}
func (r *artworkQueueRepository) Count() (int64, error) {
var res struct{ Count int64 }
err := r.queryOne(Select("count(*) as count").From(r.tableName), &res)
return res.Count, err
}
func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
var res []model.ArtworkQueueStat
err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName).
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res)
return res, err
}
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue.
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
var res model.ArtworkAbsentStat

View File

@ -411,7 +411,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
Expect(repo.CountByKindAndPriority()).To(ConsistOf(
Expect(repo.CountQueued(nil, nil)).To(ConsistOf(
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1},
model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
@ -419,7 +419,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
})
It("reports an empty queue as no rows", func() {
Expect(repo.CountByKindAndPriority()).To(BeEmpty())
Expect(repo.CountQueued(nil, nil)).To(BeEmpty())
})
It("counts absent states and how many are due for recheck", func() {
@ -442,4 +442,71 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
})
})
Describe("PurgeQueued", func() {
queuedIDs := func() []string {
GinkgoHelper()
got, err := repo.DequeueBatch(100)
Expect(err).ToNot(HaveOccurred())
return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })
}
BeforeEach(func() {
Expect(repo.Enqueue(
item("ar", "ar-backfill", model.ArtworkPriorityBackfill),
item("ar", "ar-bump", model.ArtworkPriorityBump),
item("al", "al-backfill", model.ArtworkPriorityBackfill),
item("mf", "mf-scan", model.ArtworkPriorityScan),
)).To(Succeed())
})
// CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so
// every selection must count exactly what it deletes.
DescribeTable("selects the same rows to count and to delete",
func(kinds []model.Kind, priorities []int, deleted int, remaining []string) {
counted, err := repo.CountQueued(kinds, priorities)
Expect(err).ToNot(HaveOccurred())
var total int64
for _, s := range counted {
total += s.Count
}
Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete")
Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted))
Expect(queuedIDs()).To(ConsistOf(remaining))
},
Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil,
2, []string{"al-backfill", "mf-scan"}),
Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill},
2, []string{"ar-bump", "mf-scan"}),
Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill},
1, []string{"ar-bump", "al-backfill", "mf-scan"}),
Entry("everything, when neither filter is given", nil, nil,
4, []string{}),
Entry("several kinds and priorities at once",
[]model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork},
[]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan},
2, []string{"ar-bump", "al-backfill"}),
Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil,
0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}),
)
It("deletes a row that is still backing off", func() {
backOff("ar", "ar-bump", time.Now().Add(time.Hour))
Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2))
Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)).
Error().To(MatchError(model.ErrNotFound))
})
// A WHERE clause, even one that matches everything, costs SQLite its truncate optimization
// and turns `artwork cancel --all` into a full scan of the queue.
It("adds no conditions at all for an empty filter", func() {
Expect(artworkQueueFilter(nil, nil)).To(BeEmpty())
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1))
Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1))
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})).
To(HaveLen(2))
})
})
})

View File

@ -167,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) {
return purged, nil
}
// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree.
func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool {
prefixes := model.KindPrefixes(kinds)
return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) &&
(len(priorities) == 0 || slices.Contains(priorities, it.Priority))
}
func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
return 0, m.Err
}
var purged int64
for k, it := range m.Data {
if !queueFilterMatches(it, kinds, priorities) {
continue
}
delete(m.Data, k)
purged++
}
return purged, nil
}
func (m *MockArtworkQueueRepo) Count() (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
@ -176,7 +200,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) {
return int64(len(m.Data)), nil
}
func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.Err != nil {
@ -184,6 +208,9 @@ func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueSta
}
var res []model.ArtworkQueueStat
for _, it := range m.Data {
if !queueFilterMatches(it, kinds, priorities) {
continue
}
i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool {
return s.ItemKind == it.ItemKind && s.Priority == it.Priority
})