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.
215 lines
7.1 KiB
Go
215 lines
7.1 KiB
Go
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)
|
|
}
|