package artwork import ( "context" "errors" "fmt" "slices" "strconv" "strings" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) // Result 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 Result(source string, steps []TraceStep) string { if source != "" { for _, s := range steps { if s.Outcome == OutcomeHit { break } // An external winner discards the earlier error, so the resolver settles it with no retry. if s.Outcome == OutcomeError && strings.HasPrefix(s.Candidate, ExternalPrefix) && !strings.HasPrefix(source, 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 == OutcomeError && strings.HasPrefix(s.Candidate, ExternalPrefix): return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" case s.Outcome == OutcomeError, s.Outcome == OutcomeUnreadable: return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" } } return "not resolved" } // ConfigFor names the setting that decides where a kind's artwork comes from, and its value. func ConfigFor(kind model.Kind) (setting, 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 "", "" } // ImageAgentNames names the agents that can actually supply an image for kind. func ImageAgentNames(ag *agents.Agents, kind model.Kind) []string { 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 }) } // FormatAgents accounts for every configured agent: one that cannot be constructed never reaches // the chain, so the raw list alone overstates it. Those are starred, and the bool lets each // caller word its own legend. func FormatAgents(configured string, available []string) (string, bool) { if strings.TrimSpace(configured) == "" { return "(none)", false } 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 + "*" }) return strings.Join(names, ", "), unavailable } // ExplainOptions configures a single explain. The zero value reads history and never // touches the network. type ExplainOptions struct { // Walk builds a resolver that records into the trace; nil reads the recorded trace instead. Walk func(*ChainTrace) *TracingResolver } // ExplainReport is everything known about how one item's artwork resolved. type ExplainReport struct { Kind model.Kind ID string Name string Stored *model.ItemArtwork Queued *model.ArtworkQueueItem Steps []TraceStep Source string Agents string // AgentsIncomplete reports that some configured agent is starred in Agents. AgentsIncomplete bool Walked bool ResolveErr error } // Explain gathers everything known about how kind/id's artwork resolved: stored state, the // queue row, and either the recorded trace or a fresh walk, depending on opts.Walk. func Explain(ctx context.Context, ds model.DataStore, ag *agents.Agents, kind model.Kind, id string, opts ExplainOptions) (ExplainReport, error) { name, err := ItemName(ctx, ds, kind, id) if err != nil { return ExplainReport{}, err } rep := ExplainReport{Kind: kind, ID: id, Name: name} if KeepsState(kind) { rep.Stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary) if err != nil && !errors.Is(err, model.ErrNotFound) { return ExplainReport{}, fmt.Errorf("reading artwork state: %w", err) } rep.Queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary) if err != nil && !errors.Is(err, model.ErrNotFound) { return ExplainReport{}, fmt.Errorf("reading the artwork queue: %w", err) } } // Set even for a kind the chain never walks, so a caller that asked to walk (e.g. --live on a // playlist) is reported as having tried. rep.Walked = opts.Walk != nil if !Explainable(kind) { return rep, nil } if ag != nil && (kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork) { rep.Agents, rep.AgentsIncomplete = FormatAgents(conf.Server.Agents, ImageAgentNames(ag, kind)) } switch { case rep.Walked: trace := &ChainTrace{} rep.Source, rep.ResolveErr = opts.Walk(trace).Resolve(ctx, kind, id) rep.Steps = trace.Steps() case rep.Stored != nil: rep.Steps = DecodeTrace(rep.Stored.Trace, rep.Stored.SourcePath) rep.Source = rep.Stored.Source } return rep, nil } // Result reports this report's verdict; see the package-level Result for the rules. func (r ExplainReport) Result() string { return Result(r.Source, r.Steps) } // ChainOrigin says whether the report reads history or a walk performed just now, since the two // can disagree after a config change. func (r ExplainReport) ChainOrigin() string { if r.Walked { return "walked now" } if r.Stored != nil { return "recorded " + FormatTime(r.Stored.AttemptedAt) } return "not recorded" } // FormatTime renders a timestamp for the report: a zero time reads as unset, not as year 1. func FormatTime(t time.Time) string { if t.IsZero() { return "-" } return t.Format(time.RFC3339) } // LastAttemptFailed decodes why the queued row's last attempt failed, if there is one queued. func (r ExplainReport) LastAttemptFailed() []TraceStep { if r.Queued == nil { return nil } return DecodeTrace(r.Queued.Trace, "") } // GaveUpAfter decodes the trace of the attempt that exhausted the retry budget, if the stored // state recorded one. func (r ExplainReport) GaveUpAfter() []TraceStep { if r.Stored == nil { return nil } return DecodeTrace(r.Stored.LastFailure, "") } // Priority names one of the queue's fixed priority levels. type Priority struct { Name string Value int } // KnownPriorities is the one listing behind both the name and the parse, so they cannot drift. var KnownPriorities = []Priority{ {"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) }