mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
The explainable-kind list and the report's time format each existed twice, kept in sync by a comment pointing at the other copy. Both now live in core/artwork beside their siblings. FormatAgents returned a CLI-worded sentence, so the web UI rendered starred agents with no legend at all. It now reports a bool and each caller words its own note. Drop kind, id, chainOrigin and the numeric priority from the explain response: all four were serialized and never read, and chainOrigin is derivable from stored.attemptedAt on an endpoint that never walks. ExpandInfoDialog takes an optional resource instead of a node-or-map content prop. A page mounts one dialog per resource, which removes the silent blank-dialog failure mode when a map was missing a key. Outcome chips read the MUI palette rather than hardcoded hex, so they follow the dark theme.
876 lines
30 KiB
Go
876 lines
30 KiB
Go
package cmd
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/plugins"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var explainLive bool
|
|
|
|
// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets.
|
|
var (
|
|
artworkKinds []string
|
|
artworkSources []string
|
|
artworkPriorities []string
|
|
artworkAll bool
|
|
artworkDryRun bool
|
|
artworkYes bool
|
|
)
|
|
|
|
func init() {
|
|
artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false,
|
|
"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(&artworkKinds, "kind", nil,
|
|
"kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable")
|
|
artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil,
|
|
"only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+
|
|
"or failed for the absent ones that gave up)")
|
|
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(&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)
|
|
}
|
|
|
|
var artworkCmd = &cobra.Command{
|
|
Use: "artwork",
|
|
Short: "Inspect and re-resolve artwork",
|
|
}
|
|
|
|
var artworkExplainCmd = &cobra.Command{
|
|
Use: "explain [<kind>] <id>",
|
|
Short: "Explain why an item's artwork resolved the way it did",
|
|
Long: "Explain why an item's artwork resolved the way it did.\n\n" +
|
|
"The item can be given as a bare id, a full artwork id (e.g. al-<id>), or a <kind> <id> pair.\n" +
|
|
"<kind> is one of: " + kindPrefixes(artwork.ExplainKinds) + ".\n" +
|
|
"A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2",
|
|
Args: cobra.RangeArgs(1, 2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runExplain(cmd.Context(), args)
|
|
},
|
|
}
|
|
|
|
var artworkRefreshCmd = &cobra.Command{
|
|
Use: "refresh [<kind>] <id>...",
|
|
Short: "Clear an item's artwork state and re-resolve it",
|
|
Long: "Clear an item's artwork state and re-resolve it.\n\n" +
|
|
"Each item can be given as a bare id, a full artwork id (e.g. al-<id>), or a shared\n" +
|
|
"<kind> <id>... leader. <kind> is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".",
|
|
Args: cobra.MinimumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runRefresh(cmd.Context(), args)
|
|
},
|
|
}
|
|
|
|
var artworkReprocessCmd = &cobra.Command{
|
|
Use: "reprocess",
|
|
Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from",
|
|
Args: cobra.NoArgs,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runReprocess(cmd.Context())
|
|
},
|
|
}
|
|
|
|
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" +
|
|
"reprocess, 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 config state",
|
|
Args: cobra.NoArgs,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runStatus(cmd.Context())
|
|
},
|
|
}
|
|
|
|
func runStatus(ctx context.Context) {
|
|
defer db.Init(ctx)()
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
rep, err := collectStatus(ctx, ds)
|
|
if err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
fmt.Print(formatStatus(rep))
|
|
}
|
|
|
|
type sourceCount struct {
|
|
kind model.Kind
|
|
source string
|
|
count int64
|
|
}
|
|
|
|
// absentCount partitions a kind's absent states: noImage was answered, failed gave up.
|
|
type absentCount struct {
|
|
kind model.Kind
|
|
noImage int64
|
|
failed int64
|
|
}
|
|
|
|
type statusReport struct {
|
|
queue []model.ArtworkQueueStat
|
|
sources []sourceCount
|
|
absent []absentCount
|
|
inputs []artwork.FingerprintInput
|
|
stored string
|
|
current string
|
|
}
|
|
|
|
func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) }
|
|
|
|
func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
|
var n int64
|
|
for _, s := range stats {
|
|
n += s.Count
|
|
}
|
|
return n
|
|
}
|
|
|
|
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.CountQueued(nil, nil); err != nil {
|
|
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
|
}
|
|
|
|
for _, k := range artwork.ReprocessKinds {
|
|
sources, err := q.SourcesInUse(k)
|
|
if err != nil {
|
|
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
|
}
|
|
slices.Sort(sources)
|
|
for _, s := range sources {
|
|
n, err := q.CountBySource(k, []string{s})
|
|
if err != nil {
|
|
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
|
}
|
|
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
|
// An absent state is exactly a row with no source, so it needs no second query.
|
|
if s == "" {
|
|
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
|
if err != nil {
|
|
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
|
}
|
|
rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed})
|
|
}
|
|
}
|
|
}
|
|
|
|
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
|
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
|
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
|
}
|
|
return rep, nil
|
|
}
|
|
|
|
func formatStatus(rep statusReport) string {
|
|
var sb strings.Builder
|
|
w := newTabWriter(&sb)
|
|
|
|
fmt.Fprintln(w, "Queue")
|
|
if len(rep.queue) == 0 {
|
|
fmt.Fprintln(w, " (empty)")
|
|
} else {
|
|
printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ")
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nSources")
|
|
fmt.Fprintln(w, " KIND\tSOURCE\tITEMS")
|
|
for _, s := range rep.sources {
|
|
fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count)
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nAbsent (resolved, no image found)")
|
|
fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED")
|
|
for _, a := range rep.absent {
|
|
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed)
|
|
}
|
|
fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)")
|
|
fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+
|
|
" 'artwork reprocess --source failed' retries just those)")
|
|
|
|
fmt.Fprintln(w, "\nConfig")
|
|
fmt.Fprintf(w, " State:\t%s\n", configState(rep))
|
|
fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)"))
|
|
fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current)
|
|
if len(rep.inputs) > 0 {
|
|
fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):")
|
|
for _, in := range rep.inputs {
|
|
fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value)
|
|
}
|
|
}
|
|
|
|
w.Flush()
|
|
return sb.String()
|
|
}
|
|
|
|
func configState(rep statusReport) string {
|
|
if rep.stored != rep.current {
|
|
return "fingerprint changed — stored artwork keeps the old resolution; " +
|
|
"run 'artwork reprocess --all' to apply it"
|
|
}
|
|
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), artwork.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()
|
|
}
|
|
return prefix
|
|
}
|
|
|
|
func priorityNames() string {
|
|
return strings.Join(slice.Map(artwork.KnownPriorities, func(ap artwork.Priority) string { return ap.Name }), ", ")
|
|
}
|
|
|
|
func parseArtworkPriority(s string) (int, error) {
|
|
for _, ap := range artwork.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(artworkKinds, artworkSources, artworkAll)
|
|
if err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
|
|
defer db.Init(ctx)()
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
// Only a kind that can reach an agent needs the count, and loading a plugin creates its
|
|
// services. A preview must not reach the network, so init never runs here.
|
|
var imageAgents artwork.ImageAgentCount
|
|
if needsImageAgents(kinds) {
|
|
mgr := loadPluginAgents(ctx, false)
|
|
defer func() { _ = mgr.Stop() }()
|
|
imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr))
|
|
}
|
|
|
|
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents,
|
|
artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
}
|
|
|
|
func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
|
|
// A source filter on its own is already a complete selection, so it does not also need a kind.
|
|
if all || (len(kinds) == 0 && len(sources) > 0) {
|
|
return artwork.ReprocessKinds, nil
|
|
}
|
|
if len(kinds) == 0 {
|
|
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
|
|
}
|
|
return parseAll(kinds, func(s string) (model.Kind, error) {
|
|
return parseArtworkKind(s, artwork.ReprocessKinds)
|
|
})
|
|
}
|
|
|
|
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and
|
|
// failedSource the subset of it that gave up rather than being answered.
|
|
const (
|
|
absentSource = "absent"
|
|
failedSource = "failed"
|
|
)
|
|
|
|
func repositorySources(sources []string) []string {
|
|
return slice.Map(sources, func(s string) string {
|
|
switch s {
|
|
case absentSource:
|
|
return ""
|
|
case failedSource:
|
|
return model.ArtworkSourceFailed
|
|
}
|
|
return s
|
|
})
|
|
}
|
|
|
|
func displaySource(s string) string {
|
|
if s == model.ArtworkSourceFailed {
|
|
return failedSource
|
|
}
|
|
return cmp.Or(s, absentSource)
|
|
}
|
|
|
|
type confirmFunc func(out io.Writer, total, external int64) bool
|
|
|
|
func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc {
|
|
if yes {
|
|
return func(io.Writer, int64, int64) bool { return true }
|
|
}
|
|
return promptConfirm(in, verb)
|
|
}
|
|
|
|
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
|
|
// plugin agents it counts are only the ones this process managed to load.
|
|
func externalEstimate(n int64) string {
|
|
if n == 0 {
|
|
return "none"
|
|
}
|
|
return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n)
|
|
}
|
|
|
|
func externalLookupLine(n int64) string {
|
|
return fmt.Sprintf("External lookups: %s.", externalEstimate(n))
|
|
}
|
|
|
|
// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a
|
|
// running server would. A load failure is reported, not fatal: the built-in agents still answer.
|
|
func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager {
|
|
mgr := getPluginManager()
|
|
if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil {
|
|
log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err)
|
|
}
|
|
return mgr
|
|
}
|
|
|
|
// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with
|
|
// the estimate it guards. Playlists count: their generated grid resolves album art through agents.
|
|
func needsImageAgents(kinds []model.Kind) bool {
|
|
return slices.ContainsFunc(kinds, artwork.MayFetchExternal)
|
|
}
|
|
|
|
// configuredAgents names the agents in priority order; one absent from it can never supply an image.
|
|
func configuredAgents() []string {
|
|
var names []string
|
|
for name := range strings.SplitSeq(conf.Server.Agents, ",") {
|
|
if name = strings.TrimSpace(name); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
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 %s %d items.%s Continue? [y/N] ", verb, total, cost)
|
|
var answer string
|
|
if _, err := fmt.Fscanln(in, &answer); err != nil {
|
|
return false
|
|
}
|
|
answer = strings.ToLower(strings.TrimSpace(answer))
|
|
return answer == "y" || answer == "yes"
|
|
}
|
|
}
|
|
|
|
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
|
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
|
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
|
if len(sources) == 0 {
|
|
return nil
|
|
}
|
|
var inUse []string
|
|
for _, k := range artwork.ReprocessKinds {
|
|
found, err := q.SourcesInUse(k)
|
|
if err != nil {
|
|
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
|
}
|
|
inUse = slice.Unique(append(inUse, found...))
|
|
}
|
|
var unknown []string
|
|
for _, s := range sources {
|
|
// The reserved absent and failed sources are valid even when nothing currently matches them.
|
|
if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) {
|
|
unknown = append(unknown, displaySource(s))
|
|
}
|
|
}
|
|
if len(unknown) == 0 {
|
|
return nil
|
|
}
|
|
// failed is accepted but never stored, so listing only what is in use would hide it.
|
|
valid := append(slice.Map(inUse, displaySource), failedSource)
|
|
slices.Sort(valid)
|
|
return fmt.Errorf("no artwork resolves from %s; sources in use: %s",
|
|
strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)"))
|
|
}
|
|
|
|
// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource
|
|
// actually inserted; the two differ because an already-queued row is left untouched.
|
|
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
|
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
|
q := ds.ArtworkQueue(ctx)
|
|
if err := validateSources(q, sources); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Derived from what actually drives the queries, so a filter added to this signature cannot
|
|
// silently keep stamping the fingerprint for a partial run.
|
|
markApplied := func() error {
|
|
if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) {
|
|
return nil
|
|
}
|
|
if err := artwork.MarkConfigApplied(ctx, ds); err != nil {
|
|
return fmt.Errorf("recording the applied artwork config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
matched := make([]int64, len(kinds))
|
|
var total, external int64
|
|
for i, k := range kinds {
|
|
n, err := q.CountBySource(k, sources)
|
|
if err != nil {
|
|
return fmt.Errorf("counting %s artwork: %w", k, err)
|
|
}
|
|
matched[i] = n
|
|
total += n
|
|
external += n * artwork.ExternalLookupsPerItem(k, imageAgents)
|
|
}
|
|
printReprocessPreview(out, kinds, matched, total, external, sources)
|
|
|
|
switch {
|
|
case dryRun:
|
|
fmt.Fprintln(out, "\nDry run: nothing was queued.")
|
|
return nil
|
|
case total == 0:
|
|
// An empty match set still leaves nothing resolved under the old config.
|
|
fmt.Fprintln(out, "Nothing was queued.")
|
|
return markApplied()
|
|
case !confirm(out, total, external):
|
|
fmt.Fprintln(out, "Aborted: nothing was queued.")
|
|
return nil
|
|
}
|
|
|
|
var queued int64
|
|
for i, k := range kinds {
|
|
if matched[i] == 0 {
|
|
continue
|
|
}
|
|
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
|
if err != nil {
|
|
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
|
}
|
|
queued += n
|
|
fmt.Fprintf(out, "%s: %d queued\n", k, n)
|
|
}
|
|
fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total)
|
|
if skipped := total - queued; skipped > 0 {
|
|
fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped)
|
|
}
|
|
return markApplied()
|
|
}
|
|
|
|
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 ReprocessKinds: 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) {
|
|
w := newTabWriter(out)
|
|
shown := slice.Map(sources, displaySource)
|
|
fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)"))
|
|
fmt.Fprintln(w, "KIND\tMATCHED")
|
|
for i, k := range kinds {
|
|
fmt.Fprintf(w, "%s\t%d\n", k, matched[i])
|
|
}
|
|
fmt.Fprintf(w, "TOTAL\t%d\n", total)
|
|
w.Flush()
|
|
|
|
fmt.Fprintf(out, "\n%s\n", externalLookupLine(external))
|
|
if total == 0 {
|
|
fmt.Fprintln(out, "\nNothing matches this selection.")
|
|
}
|
|
}
|
|
|
|
func runRefresh(ctx context.Context, args []string) {
|
|
defer db.Init(ctx)()
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds)
|
|
if err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
for _, f := range failures {
|
|
log.Error(ctx, "Skipping unresolved item", f)
|
|
}
|
|
failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures)
|
|
if failed > 0 {
|
|
log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures))
|
|
}
|
|
}
|
|
|
|
// refreshItems keeps going after a failure — the items are independent — and returns how many failed.
|
|
func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int {
|
|
var failed int
|
|
for _, t := range targets {
|
|
kind, id := t.Kind, t.ID
|
|
// artwork.Refresh would happily queue an id that does not exist, orphaning a queue row.
|
|
if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil {
|
|
log.Error(ctx, "Item not found", "kind", kind, "id", id, err)
|
|
failed++
|
|
continue
|
|
}
|
|
if err := artwork.Refresh(ctx, ds, kind, id); err != nil {
|
|
log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err)
|
|
failed++
|
|
continue
|
|
}
|
|
fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id)
|
|
}
|
|
return failed
|
|
}
|
|
|
|
func kindPrefixes(kinds []model.Kind) string {
|
|
return strings.Join(model.KindPrefixes(kinds), ", ")
|
|
}
|
|
|
|
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {
|
|
kind, ok := model.ParseKind(s)
|
|
if ok && slices.Contains(valid, kind) {
|
|
return kind, nil
|
|
}
|
|
return kind, invalidKindErr(s, valid)
|
|
}
|
|
|
|
func invalidKindErr(s string, valid []model.Kind) error {
|
|
return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid))
|
|
}
|
|
|
|
// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a
|
|
// shared "<kind> <id>..." leader or self-describing args (a bare id, or a full artwork id). A
|
|
// self-describing arg that cannot be resolved is returned as a failure rather than aborting the
|
|
// batch, so refresh can process the resolvable ids; a malformed <kind> leader is a usage error.
|
|
func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) {
|
|
if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 {
|
|
if !slices.Contains(valid, kind) {
|
|
return nil, nil, invalidKindErr(args[0], valid)
|
|
}
|
|
return slice.Map(args[1:], func(id string) model.ArtworkID {
|
|
return model.ArtworkID{Kind: kind, ID: id}
|
|
}), nil, nil
|
|
}
|
|
var targets []model.ArtworkID
|
|
var failures []error
|
|
for _, arg := range args {
|
|
target, err := artworkKindAndID(ctx, ds, arg)
|
|
if err == nil && !slices.Contains(valid, target.Kind) {
|
|
err = invalidKindErr(target.Kind.Prefix(), valid)
|
|
}
|
|
if err != nil {
|
|
failures = append(failures, err)
|
|
continue
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
return targets, failures, nil
|
|
}
|
|
|
|
// artworkKindAndID resolves one self-describing argument: a full artwork id (al-<id>) takes its kind
|
|
// from the prefix, a bare id is looked up. Entity ids never start with "<kind>-", so no collision.
|
|
func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) {
|
|
if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" {
|
|
return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil
|
|
}
|
|
kind, err := model.GetEntityKindByID(ctx, ds, arg)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit <kind>", arg)
|
|
}
|
|
if err != nil {
|
|
return model.ArtworkID{}, err
|
|
}
|
|
return model.ArtworkID{Kind: kind, ID: arg}, nil
|
|
}
|
|
|
|
// cliUnavailableNote marks agents the CLI cannot construct; a running server loads them all.
|
|
const cliUnavailableNote = " (* not available to the CLI)"
|
|
|
|
// cliAgents words the CLI's own legend for the starred agents FormatAgents reports.
|
|
func cliAgents(rep artwork.ExplainReport) string {
|
|
if rep.AgentsIncomplete {
|
|
return rep.Agents + cliUnavailableNote
|
|
}
|
|
return rep.Agents
|
|
}
|
|
|
|
// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and
|
|
// break the alignment, so a missing detail is rendered as a dash.
|
|
func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) {
|
|
for _, s := range steps {
|
|
fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
|
|
}
|
|
}
|
|
|
|
// writeStepTable prints a secondary trace, and nothing at all when there is none to show.
|
|
func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) {
|
|
if len(steps) == 0 {
|
|
return
|
|
}
|
|
// No tab on the title: it closes the preceding column block, so these rows align among themselves.
|
|
fmt.Fprintf(w, " %s:\n", title)
|
|
writeSteps(w, " ", steps)
|
|
}
|
|
|
|
func formatExplain(rep artwork.ExplainReport) string {
|
|
var sb strings.Builder
|
|
w := newTabWriter(&sb)
|
|
explainable := artwork.Explainable(rep.Kind)
|
|
stateful := artwork.KeepsState(rep.Kind)
|
|
unrecorded := !rep.Walked && rep.Stored == nil
|
|
|
|
fmt.Fprintln(w, "Item")
|
|
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.Kind, rep.Kind.Prefix())
|
|
fmt.Fprintf(w, " ID:\t%s\n", rep.ID)
|
|
fmt.Fprintf(w, " Name:\t%s\n", rep.Name)
|
|
|
|
fmt.Fprintln(w, "\nStored")
|
|
switch {
|
|
case !stateful:
|
|
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.Kind)
|
|
case rep.Stored == nil:
|
|
fmt.Fprintln(w, " (no artwork state recorded)")
|
|
default:
|
|
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.Stored.Source))
|
|
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.Stored.Hash, "(absent)"))
|
|
if rep.Stored.SourcePath != "" {
|
|
fmt.Fprintf(w, " Source path:\t%s\n", rep.Stored.SourcePath)
|
|
}
|
|
fmt.Fprintf(w, " Attempted at:\t%s\n", artwork.FormatTime(rep.Stored.AttemptedAt))
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nQueue")
|
|
switch {
|
|
case !stateful:
|
|
fmt.Fprintln(w, " (never queued)")
|
|
case rep.Queued == nil:
|
|
fmt.Fprintln(w, " (not queued)")
|
|
default:
|
|
fmt.Fprintf(w, " Priority:\t%s (%d)\n", artwork.PriorityName(rep.Queued.Priority), rep.Queued.Priority)
|
|
fmt.Fprintf(w, " Attempts:\t%d\n", rep.Queued.Attempts)
|
|
fmt.Fprintf(w, " Retry at:\t%s\n", artwork.FormatTime(rep.Queued.RetryAt))
|
|
}
|
|
if rep.Queued != nil {
|
|
writeStepTable(w, "Last attempt failed", rep.LastAttemptFailed())
|
|
}
|
|
if rep.Stored != nil {
|
|
writeStepTable(w, "Gave up after", rep.GaveUpAfter())
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nConfig")
|
|
if setting, value := artwork.ConfigFor(rep.Kind); setting == "" {
|
|
fmt.Fprintln(w, " (no artwork source configuration applies)")
|
|
} else {
|
|
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
|
|
if rep.Agents != "" {
|
|
fmt.Fprintf(w, " Agents:\t%s\n", cliAgents(rep))
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nChain (%s)\n", rep.ChainOrigin())
|
|
switch {
|
|
case !explainable:
|
|
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.Kind)
|
|
case unrecorded:
|
|
fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)")
|
|
case !rep.Walked && len(rep.Steps) == 0 && rep.Stored.Hash != "":
|
|
// A stored image with no chain can only predate trace recording: a recorded resolution that
|
|
// found an image always records its winning candidate.
|
|
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
|
|
case !rep.Walked && len(rep.Steps) == 0:
|
|
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
|
|
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
|
|
default:
|
|
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
|
|
writeSteps(w, " ", rep.Steps)
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nResult")
|
|
switch {
|
|
case rep.ResolveErr != nil:
|
|
fmt.Fprintf(w, " resolution failed: %s\n", rep.ResolveErr)
|
|
case !explainable:
|
|
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
|
|
case unrecorded:
|
|
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
|
|
default:
|
|
fmt.Fprintf(w, " %s\n", rep.Result())
|
|
}
|
|
|
|
w.Flush()
|
|
return sb.String()
|
|
}
|
|
|
|
func runExplain(ctx context.Context, args []string) {
|
|
defer db.Init(ctx)()
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.ExplainKinds)
|
|
if err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
if len(failures) > 0 {
|
|
log.Fatal(ctx, failures[0])
|
|
}
|
|
if len(targets) != 1 {
|
|
log.Fatal(ctx, "explain takes a single item; pass one id or a <kind> <id> pair")
|
|
}
|
|
kind, id := targets[0].Kind, targets[0].ID
|
|
|
|
var opts artwork.ExplainOptions
|
|
// Only artist and album reach an agent, and the load must precede the resolver, which reads the
|
|
// same manager. Leaving ag nil elsewhere avoids handing agents.GetAgents a not-yet-loaded manager.
|
|
var ag *agents.Agents
|
|
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
|
|
mgr := loadPluginAgents(ctx, explainLive)
|
|
defer func() { _ = mgr.Stop() }()
|
|
ag = agents.GetAgents(ds, mgr)
|
|
}
|
|
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
|
|
if explainLive || !artwork.KeepsState(kind) {
|
|
opts.Walk = func(t *artwork.ChainTrace) *artwork.TracingResolver {
|
|
return CreateArtworkResolver(t, explainLive)
|
|
}
|
|
}
|
|
rep, err := artwork.Explain(ctx, ds, ag, kind, id, opts)
|
|
if err != nil {
|
|
log.Fatal(ctx, "Failed to explain artwork", "kind", kind, "id", id, err)
|
|
}
|
|
|
|
fmt.Print(formatExplain(rep))
|
|
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
|
|
if rep.ResolveErr != nil {
|
|
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.ResolveErr)
|
|
}
|
|
}
|