mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
c26f6f9e98
commit
ffc68e29db
223
cmd/artwork.go
223
cmd/artwork.go
@ -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) {
|
||||
|
||||
@ -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")))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user