mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
1022 lines
35 KiB
Go
1022 lines
35 KiB
Go
package cmd
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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(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()
|
|
var rep statusReport
|
|
var err error
|
|
if rep.queue, err = q.CountQueued(ctx, 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(ctx, 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(ctx, 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(ctx, 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().DefaultGet(ctx, 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), 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
|
|
}
|
|
|
|
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},
|
|
{"recheck", model.ArtworkPriorityRecheck},
|
|
{"backfill", model.ArtworkPriorityBackfill},
|
|
}
|
|
|
|
// priorityName falls back to the number: a row written by a newer version still has to print.
|
|
func priorityName(p int) string {
|
|
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(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(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
|
if len(sources) == 0 {
|
|
return nil
|
|
}
|
|
var inUse []string
|
|
for _, k := range artwork.ReprocessKinds {
|
|
found, err := q.SourcesInUse(ctx, 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()
|
|
if err := validateSources(ctx, 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(ctx, 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(ctx, 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()
|
|
matched, err := q.CountQueued(ctx, 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(ctx, 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
|
|
}
|
|
|
|
// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind
|
|
// with no chain to walk still has something to answer with.
|
|
var explainKinds = []model.Kind{
|
|
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
|
|
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a
|
|
// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it.
|
|
func explainAgents(configured string, available []string) string {
|
|
if strings.TrimSpace(configured) == "" {
|
|
return "(none)"
|
|
}
|
|
var unavailable bool
|
|
names := slice.Map(strings.Split(configured, ","), func(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if slices.Contains(available, name) {
|
|
return name
|
|
}
|
|
unavailable = true
|
|
return name + "*"
|
|
})
|
|
line := strings.Join(names, ", ")
|
|
if unavailable {
|
|
line += " (* not available to the CLI)"
|
|
}
|
|
return line
|
|
}
|
|
|
|
// availableImageAgents names the agents that can actually supply an image for kind.
|
|
func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string {
|
|
ag := agents.GetAgents(ds, mgr)
|
|
if kind == model.KindArtistArtwork {
|
|
return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name })
|
|
}
|
|
return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name })
|
|
}
|
|
|
|
// explainResult states the verdict of the walk. A skipped or failed external tier, or a local
|
|
// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork.
|
|
func explainResult(source string, steps []artwork.TraceStep) string {
|
|
if source != "" {
|
|
for _, s := range steps {
|
|
if s.Outcome == artwork.OutcomeHit {
|
|
break
|
|
}
|
|
// An external winner discards the earlier error, so the resolver settles it with no retry.
|
|
if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) &&
|
|
!strings.HasPrefix(source, artwork.ExternalPrefix) {
|
|
return "resolved from " + source +
|
|
" (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)"
|
|
}
|
|
}
|
|
return "resolved from " + source
|
|
}
|
|
for _, s := range steps {
|
|
switch {
|
|
case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix):
|
|
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
|
|
// A stage error or an unreadable candidate means a source was found but not processed; the
|
|
// worker retries rather than settling absent, so neither reads as a clean miss.
|
|
case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable:
|
|
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
|
|
}
|
|
}
|
|
return "not resolved"
|
|
}
|
|
|
|
// explainConfig names the setting that decides where a kind's artwork comes from, and its value.
|
|
func explainConfig(kind model.Kind) (name, value string) {
|
|
switch kind {
|
|
case model.KindArtistArtwork:
|
|
return "ArtistArtPriority", conf.Server.ArtistArtPriority
|
|
case model.KindAlbumArtwork:
|
|
return "CoverArtPriority", conf.Server.CoverArtPriority
|
|
case model.KindDiscArtwork:
|
|
return "DiscArtPriority", conf.Server.DiscArtPriority
|
|
case model.KindMediaFileArtwork:
|
|
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
type explainReport struct {
|
|
kind model.Kind
|
|
id string
|
|
name string
|
|
stored *model.ItemArtwork
|
|
queued *model.ArtworkQueueItem
|
|
agents string
|
|
// steps is the chain walk: recorded when the item was resolved, or performed just now when walked.
|
|
steps []artwork.TraceStep
|
|
source string
|
|
walked bool
|
|
resolveErr error
|
|
}
|
|
|
|
// explainChainOrigin says whether the operator is reading history or a walk performed just now,
|
|
// since the two can disagree after a config change.
|
|
func explainChainOrigin(rep explainReport) string {
|
|
if rep.walked {
|
|
return "walked now"
|
|
}
|
|
if rep.stored != nil {
|
|
return "recorded " + formatTime(rep.stored.AttemptedAt)
|
|
}
|
|
return "not recorded"
|
|
}
|
|
|
|
// 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 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", 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", 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", formatTime(rep.queued.RetryAt))
|
|
}
|
|
if rep.queued != nil {
|
|
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
|
|
}
|
|
if rep.stored != nil {
|
|
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
|
|
}
|
|
|
|
fmt.Fprintln(w, "\nConfig")
|
|
if setting, value := explainConfig(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", rep.agents)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep))
|
|
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", explainResult(rep.source, rep.steps))
|
|
}
|
|
|
|
w.Flush()
|
|
return sb.String()
|
|
}
|
|
|
|
func formatTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "-"
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
func runExplain(ctx context.Context, args []string) {
|
|
defer db.Init(ctx)()
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
targets, failures, err := resolveArtworkTargets(ctx, ds, args, 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
|
|
|
|
name, err := artwork.ItemName(ctx, ds, kind, id)
|
|
if err != nil {
|
|
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
|
|
}
|
|
rep := explainReport{kind: kind, id: id, name: name}
|
|
if artwork.KeepsState(kind) {
|
|
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
|
}
|
|
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
|
}
|
|
}
|
|
|
|
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
|
|
rep.walked = explainLive || !artwork.KeepsState(kind)
|
|
if artwork.Explainable(kind) {
|
|
// Only artist and album reach an agent, and the load must precede the resolver, which reads
|
|
// the same manager.
|
|
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
|
|
mgr := loadPluginAgents(ctx, explainLive)
|
|
defer func() { _ = mgr.Stop() }()
|
|
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
|
|
}
|
|
switch {
|
|
case rep.walked:
|
|
trace := &artwork.ChainTrace{}
|
|
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
|
|
rep.steps = trace.Steps()
|
|
case rep.stored != nil:
|
|
rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath)
|
|
rep.source = rep.stored.Source
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|