mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-16 06:17:11 +02:00
Compare commits
14 commits
master
...
artwork-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a1b3b5bd |
||
|
|
8ba8e7f2d7 | ||
|
|
f283da0630 | ||
|
|
fd46e594e1 | ||
|
|
687646809f | ||
|
|
eadbb442a8 | ||
|
|
e7f7e7bde9 | ||
|
|
d17ef212ab | ||
|
|
0fcce273a2 | ||
|
|
395f2f9ae5 | ||
|
|
b8c7f3cd2b | ||
|
|
03743a030b | ||
|
|
0ab549e1b2 | ||
|
|
3290759e62 |
37 changed files with 1499 additions and 399 deletions
276
cmd/artwork.go
276
cmd/artwork.go
|
|
@ -8,9 +8,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
@ -76,7 +74,7 @@ var artworkExplainCmd = &cobra.Command{
|
||||||
Short: "Explain why an item's artwork resolved the way it did",
|
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" +
|
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" +
|
"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" +
|
"<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",
|
"A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2",
|
||||||
Args: cobra.RangeArgs(1, 2),
|
Args: cobra.RangeArgs(1, 2),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
@ -264,7 +262,7 @@ func configState(rep statusReport) string {
|
||||||
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
|
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
|
||||||
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
|
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
|
||||||
for _, s := range stats {
|
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, "%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)
|
fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total)
|
||||||
}
|
}
|
||||||
|
|
@ -276,37 +274,14 @@ func kindName(prefix string) string {
|
||||||
return prefix
|
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 {
|
func priorityNames() string {
|
||||||
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
|
return strings.Join(slice.Map(artwork.KnownPriorities, func(ap artwork.Priority) string { return ap.Name }), ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseArtworkPriority(s string) (int, error) {
|
func parseArtworkPriority(s string) (int, error) {
|
||||||
for _, ap := range knownPriorities {
|
for _, ap := range artwork.KnownPriorities {
|
||||||
if ap.name == s {
|
if ap.Name == s {
|
||||||
return ap.value, nil
|
return ap.Value, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
|
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
|
||||||
|
|
@ -678,13 +653,6 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo
|
||||||
return failed
|
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 {
|
func kindPrefixes(kinds []model.Kind) string {
|
||||||
return strings.Join(model.KindPrefixes(kinds), ", ")
|
return strings.Join(model.KindPrefixes(kinds), ", ")
|
||||||
}
|
}
|
||||||
|
|
@ -746,106 +714,15 @@ func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (mode
|
||||||
return model.ArtworkID{Kind: kind, ID: arg}, nil
|
return model.ArtworkID{Kind: kind, ID: arg}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a
|
// cliUnavailableNote marks agents the CLI cannot construct; a running server loads them all.
|
||||||
// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it.
|
const cliUnavailableNote = " (* not available to the CLI)"
|
||||||
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.
|
// cliAgents words the CLI's own legend for the starred agents FormatAgents reports.
|
||||||
func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string {
|
func cliAgents(rep artwork.ExplainReport) string {
|
||||||
ag := agents.GetAgents(ds, mgr)
|
if rep.AgentsIncomplete {
|
||||||
if kind == model.KindArtistArtwork {
|
return rep.Agents + cliUnavailableNote
|
||||||
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 })
|
return rep.Agents
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and
|
||||||
|
|
@ -866,107 +743,100 @@ func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) {
|
||||||
writeSteps(w, " ", steps)
|
writeSteps(w, " ", steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatExplain(rep explainReport) string {
|
func formatExplain(rep artwork.ExplainReport) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
w := newTabWriter(&sb)
|
w := newTabWriter(&sb)
|
||||||
explainable := artwork.Explainable(rep.kind)
|
explainable := artwork.Explainable(rep.Kind)
|
||||||
stateful := artwork.KeepsState(rep.kind)
|
stateful := artwork.KeepsState(rep.Kind)
|
||||||
unrecorded := !rep.walked && rep.stored == nil
|
unrecorded := !rep.Walked && rep.Stored == nil
|
||||||
|
|
||||||
fmt.Fprintln(w, "Item")
|
fmt.Fprintln(w, "Item")
|
||||||
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
|
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, " ID:\t%s\n", rep.ID)
|
||||||
fmt.Fprintf(w, " Name:\t%s\n", rep.name)
|
fmt.Fprintf(w, " Name:\t%s\n", rep.Name)
|
||||||
|
|
||||||
fmt.Fprintln(w, "\nStored")
|
fmt.Fprintln(w, "\nStored")
|
||||||
switch {
|
switch {
|
||||||
case !stateful:
|
case !stateful:
|
||||||
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind)
|
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.Kind)
|
||||||
case rep.stored == nil:
|
case rep.Stored == nil:
|
||||||
fmt.Fprintln(w, " (no artwork state recorded)")
|
fmt.Fprintln(w, " (no artwork state recorded)")
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source))
|
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.Stored.Source))
|
||||||
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)"))
|
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.Stored.Hash, "(absent)"))
|
||||||
if rep.stored.SourcePath != "" {
|
if rep.Stored.SourcePath != "" {
|
||||||
fmt.Fprintf(w, " Source path:\t%s\n", 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.Fprintf(w, " Attempted at:\t%s\n", artwork.FormatTime(rep.Stored.AttemptedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(w, "\nQueue")
|
fmt.Fprintln(w, "\nQueue")
|
||||||
switch {
|
switch {
|
||||||
case !stateful:
|
case !stateful:
|
||||||
fmt.Fprintln(w, " (never queued)")
|
fmt.Fprintln(w, " (never queued)")
|
||||||
case rep.queued == nil:
|
case rep.Queued == nil:
|
||||||
fmt.Fprintln(w, " (not queued)")
|
fmt.Fprintln(w, " (not queued)")
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority)
|
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, " Attempts:\t%d\n", rep.Queued.Attempts)
|
||||||
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
|
fmt.Fprintf(w, " Retry at:\t%s\n", artwork.FormatTime(rep.Queued.RetryAt))
|
||||||
}
|
}
|
||||||
if rep.queued != nil {
|
if rep.Queued != nil {
|
||||||
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
|
writeStepTable(w, "Last attempt failed", rep.LastAttemptFailed())
|
||||||
}
|
}
|
||||||
if rep.stored != nil {
|
if rep.Stored != nil {
|
||||||
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
|
writeStepTable(w, "Gave up after", rep.GaveUpAfter())
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(w, "\nConfig")
|
fmt.Fprintln(w, "\nConfig")
|
||||||
if setting, value := explainConfig(rep.kind); setting == "" {
|
if setting, value := artwork.ConfigFor(rep.Kind); setting == "" {
|
||||||
fmt.Fprintln(w, " (no artwork source configuration applies)")
|
fmt.Fprintln(w, " (no artwork source configuration applies)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
|
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
|
||||||
if rep.agents != "" {
|
if rep.Agents != "" {
|
||||||
fmt.Fprintf(w, " Agents:\t%s\n", rep.agents)
|
fmt.Fprintf(w, " Agents:\t%s\n", cliAgents(rep))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep))
|
fmt.Fprintf(w, "\nChain (%s)\n", rep.ChainOrigin())
|
||||||
switch {
|
switch {
|
||||||
case !explainable:
|
case !explainable:
|
||||||
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
|
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.Kind)
|
||||||
case unrecorded:
|
case unrecorded:
|
||||||
fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)")
|
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 != "":
|
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
|
// A stored image with no chain can only predate trace recording: a recorded resolution that
|
||||||
// found an image always records its winning candidate.
|
// found an image always records its winning candidate.
|
||||||
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
|
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
|
||||||
case !rep.walked && len(rep.steps) == 0:
|
case !rep.Walked && len(rep.Steps) == 0:
|
||||||
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
|
// 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)")
|
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
|
||||||
default:
|
default:
|
||||||
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
|
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
|
||||||
writeSteps(w, " ", rep.steps)
|
writeSteps(w, " ", rep.Steps)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(w, "\nResult")
|
fmt.Fprintln(w, "\nResult")
|
||||||
switch {
|
switch {
|
||||||
case rep.resolveErr != nil:
|
case rep.ResolveErr != nil:
|
||||||
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
|
fmt.Fprintf(w, " resolution failed: %s\n", rep.ResolveErr)
|
||||||
case !explainable:
|
case !explainable:
|
||||||
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
|
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
|
||||||
case unrecorded:
|
case unrecorded:
|
||||||
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
|
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
|
fmt.Fprintf(w, " %s\n", rep.Result())
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
w.Flush()
|
||||||
return sb.String()
|
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) {
|
func runExplain(ctx context.Context, args []string) {
|
||||||
defer db.Init(ctx)()
|
defer db.Init(ctx)()
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds)
|
targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.ExplainKinds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, err)
|
log.Fatal(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
@ -978,45 +848,29 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
}
|
}
|
||||||
kind, id := targets[0].Kind, targets[0].ID
|
kind, id := targets[0].Kind, targets[0].ID
|
||||||
|
|
||||||
name, err := artwork.ItemName(ctx, ds, kind, id)
|
var opts artwork.ExplainOptions
|
||||||
if err != nil {
|
// Only artist and album reach an agent, and the load must precede the resolver, which reads the
|
||||||
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
|
// 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)
|
||||||
}
|
}
|
||||||
rep := explainReport{kind: kind, id: id, name: name}
|
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
|
||||||
if artwork.KeepsState(kind) {
|
if explainLive || !artwork.KeepsState(kind) {
|
||||||
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
opts.Walk = func(t *artwork.ChainTrace) *artwork.TracingResolver {
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
return CreateArtworkResolver(t, explainLive)
|
||||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
|
||||||
}
|
|
||||||
rep.queued, err = ds.ArtworkQueue(ctx).Get(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// 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))
|
fmt.Print(formatExplain(rep))
|
||||||
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
|
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
|
||||||
if rep.resolveErr != nil {
|
if rep.ResolveErr != nil {
|
||||||
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr)
|
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.ResolveErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ var _ = Describe("parseArtworkKind", func() {
|
||||||
_, err := parseArtworkKind(prefix, valid)
|
_, err := parseArtworkKind(prefix, valid)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
},
|
},
|
||||||
Entry("explain reads disc artwork", "dc", explainKinds),
|
Entry("explain reads disc artwork", "dc", artwork.ExplainKinds),
|
||||||
Entry("explain reads media file artwork", "mf", explainKinds),
|
Entry("explain reads media file artwork", "mf", artwork.ExplainKinds),
|
||||||
// Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not
|
// Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not
|
||||||
// accept it: the queue row would be rejected on every drain.
|
// accept it: the queue row would be rejected on every drain.
|
||||||
Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds),
|
Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds),
|
||||||
|
|
@ -65,7 +65,7 @@ var _ = Describe("resolveArtworkTargets", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("accepts the explicit <kind> <id> leader shared by every id", func() {
|
It("accepts the explicit <kind> <id> leader shared by every id", func() {
|
||||||
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds)
|
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(failures).To(BeEmpty())
|
Expect(failures).To(BeEmpty())
|
||||||
Expect(targets).To(Equal([]model.ArtworkID{
|
Expect(targets).To(Equal([]model.ArtworkID{
|
||||||
|
|
@ -78,20 +78,20 @@ var _ = Describe("resolveArtworkTargets", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("resolves a bare id by looking it up across tables", func() {
|
It("resolves a bare id by looking it up across tables", func() {
|
||||||
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds)
|
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(failures).To(BeEmpty())
|
Expect(failures).To(BeEmpty())
|
||||||
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}}))
|
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reads the kind from a full artwork id prefix without a database lookup", func() {
|
It("reads the kind from a full artwork id prefix without a database lookup", func() {
|
||||||
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds)
|
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("strips the hash suffix from a full artwork id", func() {
|
It("strips the hash suffix from a full artwork id", func() {
|
||||||
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds)
|
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
||||||
})
|
})
|
||||||
|
|
@ -105,7 +105,7 @@ var _ = Describe("resolveArtworkTargets", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("collects an id that matches nothing and has no kind prefix", func() {
|
It("collects an id that matches nothing and has no kind prefix", func() {
|
||||||
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds)
|
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(targets).To(BeEmpty())
|
Expect(targets).To(BeEmpty())
|
||||||
Expect(failures).To(HaveLen(1))
|
Expect(failures).To(HaveLen(1))
|
||||||
|
|
@ -113,7 +113,7 @@ var _ = Describe("resolveArtworkTargets", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("resolves the valid ids and collects the unresolvable ones", func() {
|
It("resolves the valid ids and collects the unresolvable ones", func() {
|
||||||
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds)
|
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, artwork.ExplainKinds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(targets).To(Equal([]model.ArtworkID{
|
Expect(targets).To(Equal([]model.ArtworkID{
|
||||||
{Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
{Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
|
||||||
|
|
@ -122,126 +122,36 @@ var _ = Describe("resolveArtworkTargets", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("explainResult", func() {
|
|
||||||
It("reports the winning source", func() {
|
|
||||||
steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}}
|
|
||||||
Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports not resolved when every candidate was tried and missed", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "artist.*", Outcome: "miss"},
|
|
||||||
{Candidate: "external:deezer", Outcome: "miss"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("", steps)).To(Equal("not resolved"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports indeterminate when a local candidate exists but could not be read", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "cover.*", Outcome: "miss"},
|
|
||||||
{Candidate: "embedded", Outcome: "unreadable"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
|
|
||||||
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports indeterminate when a processing stage errored after a candidate was found", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
|
|
||||||
{Candidate: "store", Outcome: "error", Detail: "disk full"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
|
|
||||||
"a stage error is a processing failure the worker retries, not a definitive miss")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not qualify a hit that an earlier unreadable candidate preceded", func() {
|
|
||||||
// chainState.try stamps only the external error onto a hit and drops the local one, so the
|
|
||||||
// worker settles this as found; warning about it would be a false alarm.
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "embedded", Outcome: "unreadable"},
|
|
||||||
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("folder", steps)).To(Equal("resolved from folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports indeterminate when an external lookup failed transiently", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "artist.*", Outcome: "miss"},
|
|
||||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
|
|
||||||
"a failed network call is not evidence that the item has no artwork")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("qualifies a win a failed higher-priority external lookup could have taken", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
|
||||||
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
|
|
||||||
}
|
|
||||||
res := explainResult("artist.*", steps)
|
|
||||||
Expect(res).To(ContainSubstring("resolved from artist.*"))
|
|
||||||
Expect(res).To(ContainSubstring("indeterminate"),
|
|
||||||
"the resolver serves this hit but retries later, so the winner is provisional")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not qualify an external win that followed a failed external lookup", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
|
||||||
{Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"),
|
|
||||||
"a later agent supplying the image discards the earlier error, so there is no retry to warn about")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not qualify a win that outranked the failed external lookup", func() {
|
|
||||||
steps := []artwork.TraceStep{
|
|
||||||
{Candidate: "artist.*", Outcome: "hit"},
|
|
||||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
|
||||||
}
|
|
||||||
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("explainAgents", func() {
|
|
||||||
It("accounts for every configured agent, marking the ones the CLI could not use", func() {
|
|
||||||
out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"})
|
|
||||||
for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} {
|
|
||||||
Expect(out).To(ContainSubstring(name),
|
|
||||||
"a configured agent missing from this line reads as if it had never been configured")
|
|
||||||
}
|
|
||||||
Expect(out).To(ContainSubstring("not available to the CLI"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not mark anything when every configured agent is available", func() {
|
|
||||||
out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"})
|
|
||||||
Expect(out).To(Equal("deezer, lastfm"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports an empty configuration as none, not as an unavailable agent", func() {
|
|
||||||
Expect(explainAgents("", nil)).To(Equal("(none)"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatExplain", func() {
|
var _ = Describe("formatExplain", func() {
|
||||||
var rep explainReport
|
var rep artwork.ExplainReport
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
conf.Server.ArtistArtPriority = "external, artist.*"
|
conf.Server.ArtistArtPriority = "external, artist.*"
|
||||||
rep = explainReport{
|
rep = artwork.ExplainReport{
|
||||||
kind: model.KindArtistArtwork,
|
Kind: model.KindArtistArtwork,
|
||||||
id: "ar-1",
|
ID: "ar-1",
|
||||||
name: "Radiohead",
|
Name: "Radiohead",
|
||||||
agents: "lastfm,spotify",
|
Agents: "lastfm,spotify",
|
||||||
walked: true,
|
Walked: true,
|
||||||
steps: []artwork.TraceStep{
|
Steps: []artwork.TraceStep{
|
||||||
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
|
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
|
||||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
||||||
},
|
},
|
||||||
source: "",
|
Source: "",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("explains the star on an agent the CLI could not construct", func() {
|
||||||
|
rep.AgentsIncomplete = true
|
||||||
|
|
||||||
|
Expect(formatExplain(rep)).To(ContainSubstring("(* not available to the CLI)"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves the agent line unadorned when every agent is available", func() {
|
||||||
|
Expect(formatExplain(rep)).ToNot(ContainSubstring("not available to the CLI"))
|
||||||
|
})
|
||||||
|
|
||||||
It("reports the item, its config and the chain it walked", func() {
|
It("reports the item, its config and the chain it walked", func() {
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).To(ContainSubstring("Radiohead"))
|
Expect(out).To(ContainSubstring("Radiohead"))
|
||||||
|
|
@ -260,11 +170,11 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
It("prints the stored state and the queue row when they exist", func() {
|
It("prints the stored state and the queue row when they exist", func() {
|
||||||
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123",
|
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc123",
|
||||||
SourcePath: "/music/cover.jpg", AttemptedAt: attempted}
|
SourcePath: "/music/cover.jpg", AttemptedAt: attempted}
|
||||||
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
|
rep.Queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
|
||||||
RetryAt: attempted.Add(time.Hour)}
|
RetryAt: attempted.Add(time.Hour)}
|
||||||
rep.source = "folder"
|
rep.Source = "folder"
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).To(ContainSubstring("abc123"))
|
Expect(out).To(ContainSubstring("abc123"))
|
||||||
|
|
@ -275,12 +185,12 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("marks a known-absent stored state instead of printing an empty hash", func() {
|
It("marks a known-absent stored state instead of printing an empty hash", func() {
|
||||||
rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()}
|
rep.Stored = &model.ItemArtwork{AttemptedAt: time.Now()}
|
||||||
Expect(formatExplain(rep)).To(ContainSubstring("absent"))
|
Expect(formatExplain(rep)).To(ContainSubstring("absent"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports a failed walk as failed, not as unresolved", func() {
|
It("reports a failed walk as failed, not as unresolved", func() {
|
||||||
rep.resolveErr = errors.New("no such directory")
|
rep.ResolveErr = errors.New("no such directory")
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
|
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
|
||||||
|
|
@ -290,9 +200,9 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
|
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
|
||||||
conf.Server.CoverArtPriority = "cover.*, embedded"
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||||
rep.kind = model.KindPlaylistArtwork
|
rep.Kind = model.KindPlaylistArtwork
|
||||||
rep.steps = nil
|
rep.Steps = nil
|
||||||
rep.agents = ""
|
rep.Agents = ""
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).To(ContainSubstring("does not walk a priority chain"))
|
Expect(out).To(ContainSubstring("does not walk a priority chain"))
|
||||||
|
|
@ -306,11 +216,11 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
|
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
|
||||||
conf.Server.DiscArtPriority = "cover.jpg, embedded"
|
conf.Server.DiscArtPriority = "cover.jpg, embedded"
|
||||||
rep = explainReport{
|
rep = artwork.ExplainReport{
|
||||||
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
|
Kind: model.KindDiscArtwork, ID: "al-1:2", Name: "OK Computer (disc 2)",
|
||||||
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
|
Steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
|
||||||
source: "folder",
|
Source: "folder",
|
||||||
walked: true,
|
Walked: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
|
|
@ -325,15 +235,15 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
Context("stored traces", func() {
|
Context("stored traces", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
rep.walked = false
|
rep.Walked = false
|
||||||
rep.steps = nil
|
rep.Steps = nil
|
||||||
})
|
})
|
||||||
|
|
||||||
It("labels a recorded chain with when it was recorded, not as a walk done now", func() {
|
It("labels a recorded chain with when it was recorded, not as a walk done now", func() {
|
||||||
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
|
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
|
||||||
rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
|
rep.Steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
|
||||||
rep.source = "folder"
|
rep.Source = "folder"
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)"))
|
Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)"))
|
||||||
|
|
@ -350,7 +260,7 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("distinguishes a row written before traces existed from one with an empty chain", func() {
|
It("distinguishes a row written before traces existed from one with an empty chain", func() {
|
||||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
|
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
|
||||||
|
|
||||||
Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded"))
|
Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded"))
|
||||||
})
|
})
|
||||||
|
|
@ -358,7 +268,7 @@ var _ = Describe("formatExplain", func() {
|
||||||
It("does not call an absent row with an empty recorded chain a pre-tracing row", func() {
|
It("does not call an absent row with an empty recorded chain a pre-tracing row", func() {
|
||||||
// An empty priority list records a real but empty chain and resolves absent; that is not a
|
// An empty priority list records a real but empty chain and resolves absent; that is not a
|
||||||
// legacy row, so it must not be reported as resolved before tracing existed.
|
// legacy row, so it must not be reported as resolved before tracing existed.
|
||||||
rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
|
rep.Stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
Expect(out).ToNot(ContainSubstring("resolved before traces were recorded"))
|
Expect(out).ToNot(ContainSubstring("resolved before traces were recorded"))
|
||||||
|
|
@ -367,9 +277,9 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("prints why the last attempt failed and why it gave up", func() {
|
It("prints why the last attempt failed and why it gave up", func() {
|
||||||
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
|
rep.Queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
|
||||||
Trace: `[{"c":"decode","o":"error","d":"bad header"}]`}
|
Trace: `[{"c":"decode","o":"error","d":"bad header"}]`}
|
||||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
|
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
|
||||||
LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`}
|
LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`}
|
||||||
|
|
||||||
out := formatExplain(rep)
|
out := formatExplain(rep)
|
||||||
|
|
@ -388,10 +298,10 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
It("reports the setting that governs media file artwork", func() {
|
It("reports the setting that governs media file artwork", func() {
|
||||||
conf.Server.EnableMediaFileCoverArt = false
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
rep = explainReport{
|
rep = artwork.ExplainReport{
|
||||||
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
|
Kind: model.KindMediaFileArtwork, ID: "mf-1", Name: "Airbag",
|
||||||
walked: true,
|
Walked: true,
|
||||||
steps: []artwork.TraceStep{
|
Steps: []artwork.TraceStep{
|
||||||
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
|
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -404,25 +314,6 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("explainConfig", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.DiscArtPriority = "cover.jpg"
|
|
||||||
conf.Server.EnableMediaFileCoverArt = true
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("names the setting that decides where a kind's artwork comes from",
|
|
||||||
func(kind model.Kind, setting, value string) {
|
|
||||||
gotSetting, gotValue := explainConfig(kind)
|
|
||||||
Expect(gotSetting).To(Equal(setting))
|
|
||||||
Expect(gotValue).To(Equal(value))
|
|
||||||
},
|
|
||||||
Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"),
|
|
||||||
Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"),
|
|
||||||
Entry("playlist has none", model.KindPlaylistArtwork, "", ""),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("artwork refresh command", func() {
|
var _ = Describe("artwork refresh command", func() {
|
||||||
It("requires at least one argument", func() {
|
It("requires at least one argument", func() {
|
||||||
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred())
|
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred())
|
||||||
|
|
@ -495,8 +386,8 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
|
|
||||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
shown := storedSource(formatExplain(artwork.ExplainReport{Kind: model.KindArtistArtwork, ID: "ar-1",
|
||||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
Stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||||
|
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue(ctx)
|
||||||
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
||||||
|
|
@ -1059,7 +950,7 @@ var _ = Describe("parseArtworkPriority", func() {
|
||||||
It("accepts every name status prints", func() {
|
It("accepts every name status prints", func() {
|
||||||
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
|
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
|
||||||
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
|
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
|
||||||
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
|
Expect(parseArtworkPriority(artwork.PriorityName(p))).To(Equal(p))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, agentsAgents)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
215
core/artwork/explain.go
Normal file
215
core/artwork/explain.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
213
core/artwork/explain_test.go
Normal file
213
core/artwork/explain_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
package artwork_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Result", func() {
|
||||||
|
It("reports the source when one was found", func() {
|
||||||
|
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeHit}}
|
||||||
|
Expect(artwork.Result("folder", steps)).To(Equal("resolved from folder"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("flags a local win that a failed external lookup could have outranked", func() {
|
||||||
|
steps := []artwork.TraceStep{
|
||||||
|
{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeError, Detail: "timeout"},
|
||||||
|
{Candidate: "cover.*", Outcome: artwork.OutcomeHit},
|
||||||
|
}
|
||||||
|
Expect(artwork.Result("folder", steps)).To(ContainSubstring("indeterminate"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not flag an external winner", func() {
|
||||||
|
steps := []artwork.TraceStep{
|
||||||
|
{Candidate: artwork.ExternalPrefix + "lastfm", Outcome: artwork.OutcomeError},
|
||||||
|
{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeHit},
|
||||||
|
}
|
||||||
|
Expect(artwork.Result(artwork.ExternalPrefix+"deezer", steps)).
|
||||||
|
To(Equal("resolved from external:deezer"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is indeterminate when an external lookup failed and nothing resolved", func() {
|
||||||
|
steps := []artwork.TraceStep{{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeError}}
|
||||||
|
Expect(artwork.Result("", steps)).To(ContainSubstring("an external lookup failed"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is indeterminate when a candidate could not be processed", func() {
|
||||||
|
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeUnreadable}}
|
||||||
|
Expect(artwork.Result("", steps)).To(ContainSubstring("could not be processed"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports a clean miss", func() {
|
||||||
|
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeMiss}}
|
||||||
|
Expect(artwork.Result("", steps)).To(Equal("not resolved"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("ConfigFor", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.ArtistArtPriority = "external"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("names the setting for a kind that has one", func() {
|
||||||
|
setting, value := artwork.ConfigFor(model.KindArtistArtwork)
|
||||||
|
Expect(setting).To(Equal("ArtistArtPriority"))
|
||||||
|
Expect(value).To(Equal("external"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("names the setting for the album kind", func() {
|
||||||
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||||
|
setting, value := artwork.ConfigFor(model.KindAlbumArtwork)
|
||||||
|
Expect(setting).To(Equal("CoverArtPriority"))
|
||||||
|
Expect(value).To(Equal("cover.*, embedded"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns nothing for a kind with no source configuration", func() {
|
||||||
|
setting, _ := artwork.ConfigFor(model.KindPlaylistArtwork)
|
||||||
|
Expect(setting).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("FormatAgents", func() {
|
||||||
|
It("reports none when nothing is configured", func() {
|
||||||
|
line, incomplete := artwork.FormatAgents(" ", nil)
|
||||||
|
Expect(line).To(Equal("(none)"))
|
||||||
|
Expect(incomplete).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the configured order and marks what is unavailable", func() {
|
||||||
|
got, incomplete := artwork.FormatAgents("spotify, lastfm", []string{"lastfm"})
|
||||||
|
Expect(incomplete).To(BeTrue())
|
||||||
|
Expect(got).To(Equal("spotify*, lastfm"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports complete when every configured agent is available", func() {
|
||||||
|
line, incomplete := artwork.FormatAgents("lastfm", []string{"lastfm"})
|
||||||
|
Expect(line).To(Equal("lastfm"))
|
||||||
|
Expect(incomplete).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("PriorityName", func() {
|
||||||
|
It("names a known priority", func() {
|
||||||
|
Expect(artwork.PriorityName(model.ArtworkPriorityScan)).To(Equal("scan"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to the number for an unknown priority", func() {
|
||||||
|
Expect(artwork.PriorityName(999)).To(Equal("999"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("Explain", func() {
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var artRepo *tests.MockArtworkRepo
|
||||||
|
var queueRepo *tests.MockArtworkQueueRepo
|
||||||
|
var ctx context.Context
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = context.Background()
|
||||||
|
artRepo = tests.CreateMockArtworkRepo()
|
||||||
|
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||||
|
ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
|
||||||
|
Expect(ds.Artist(ctx).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns an error when the item does not exist", func() {
|
||||||
|
_, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "nope", artwork.ExplainOptions{})
|
||||||
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reads the recorded trace when no walker is supplied", func() {
|
||||||
|
// Storage shape mirrors storedStep in trace.go: c=candidate, o=outcome, d=detail.
|
||||||
|
trace := `[{"c":"external:deezer","o":"hit","d":"https://cdn/x.jpg"}]`
|
||||||
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
|
Hash: "abc", Source: "external:deezer",
|
||||||
|
Trace: trace,
|
||||||
|
AttemptedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC),
|
||||||
|
})).To(Succeed())
|
||||||
|
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Name).To(Equal("Radiohead"))
|
||||||
|
Expect(rep.Walked).To(BeFalse())
|
||||||
|
Expect(rep.Steps).To(Equal([]artwork.TraceStep{
|
||||||
|
{Candidate: "external:deezer", Outcome: artwork.OutcomeHit, Detail: "https://cdn/x.jpg"},
|
||||||
|
}))
|
||||||
|
Expect(rep.Source).To(Equal("external:deezer"))
|
||||||
|
Expect(rep.Result()).To(Equal("resolved from external:deezer"))
|
||||||
|
Expect(rep.ChainOrigin()).To(ContainSubstring("recorded"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("renders a zero attempted-at as unset, not as year 1", func() {
|
||||||
|
rep := artwork.ExplainReport{Stored: &model.ItemArtwork{Source: "folder"}}
|
||||||
|
Expect(rep.ChainOrigin()).To(Equal("recorded -"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports nothing recorded when there is no stored state", func() {
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Stored).To(BeNil())
|
||||||
|
Expect(rep.Steps).To(BeEmpty())
|
||||||
|
Expect(rep.ChainOrigin()).To(Equal("not recorded"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes the queue row and its failure trace", func() {
|
||||||
|
failure := `[{"c":"external:lastfm","o":"error","d":"429"}]`
|
||||||
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
|
Priority: model.ArtworkPriorityScan,
|
||||||
|
})).To(Succeed())
|
||||||
|
queueRepo.SetTrace(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary, failure)
|
||||||
|
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Queued).ToNot(BeNil())
|
||||||
|
Expect(rep.Queued.Priority).To(Equal(model.ArtworkPriorityScan))
|
||||||
|
Expect(rep.LastAttemptFailed()).To(Equal([]artwork.TraceStep{
|
||||||
|
{Candidate: "external:lastfm", Outcome: artwork.OutcomeError, Detail: "429"},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("records nothing for a kind that keeps no state and has no walker", func() {
|
||||||
|
Expect(ds.Album(ctx).Put(&model.Album{ID: "al-1", Name: "OK Computer"})).To(Succeed())
|
||||||
|
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindDiscArtwork, "al-1:2", artwork.ExplainOptions{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Stored).To(BeNil())
|
||||||
|
Expect(rep.Steps).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("performs a fresh walk and reports its outcome, including a failed one, when a walker is supplied", func() {
|
||||||
|
// GetAll erroring mid-walk is a deterministic way to force ResolveErr without a real library.
|
||||||
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
|
||||||
|
opts := artwork.ExplainOptions{Walk: func(t *artwork.ChainTrace) *artwork.TracingResolver {
|
||||||
|
return artwork.NewTracingResolver(ds, nil, nil, t, false)
|
||||||
|
}}
|
||||||
|
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", opts)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Walked).To(BeTrue())
|
||||||
|
Expect(rep.ResolveErr).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("still reports Walked for a kind with no chain to walk, when a walker is supplied", func() {
|
||||||
|
Expect(ds.Playlist(ctx).Put(&model.Playlist{ID: "pl-1", Name: "Favorites"})).To(Succeed())
|
||||||
|
opts := artwork.ExplainOptions{Walk: func(*artwork.ChainTrace) *artwork.TracingResolver {
|
||||||
|
panic("a kind that cannot walk must never be handed to the walker")
|
||||||
|
}}
|
||||||
|
|
||||||
|
rep, err := artwork.Explain(ctx, ds, nil, model.KindPlaylistArtwork, "pl-1", opts)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rep.Walked).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -25,6 +25,13 @@ var ReprocessKinds = []model.Kind{
|
||||||
// artwork is read through on every request and cached by content key, so it has neither.
|
// artwork is read through on every request and cached by content key, so it has neither.
|
||||||
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
|
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
|
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
|
||||||
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
|
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
|
||||||
// resolves them, it just never enumerates them in bulk.
|
// resolves them, it just never enumerates them in bulk.
|
||||||
|
|
|
||||||
133
server/nativeapi/artwork_explain.go
Normal file
133
server/nativeapi/artwork_explain.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type traceStepDTO struct {
|
||||||
|
Candidate string `json:"candidate"`
|
||||||
|
Outcome string `json:"outcome"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedDTO struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourcePath string `json:"sourcePath,omitempty"`
|
||||||
|
AttemptedAt string `json:"attemptedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type queuedDTO struct {
|
||||||
|
PriorityName string `json:"priorityName"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
RetryAt string `json:"retryAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDTO struct {
|
||||||
|
Setting string `json:"setting"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type explainDTO struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Steps []traceStepDTO `json:"steps"`
|
||||||
|
Stored *storedDTO `json:"stored,omitempty"`
|
||||||
|
Queued *queuedDTO `json:"queued,omitempty"`
|
||||||
|
LastAttemptFailed []traceStepDTO `json:"lastAttemptFailed,omitempty"`
|
||||||
|
GaveUpAfter []traceStepDTO `json:"gaveUpAfter,omitempty"`
|
||||||
|
Config *configDTO `json:"config,omitempty"`
|
||||||
|
Agents string `json:"agents,omitempty"`
|
||||||
|
AgentsIncomplete bool `json:"agentsIncomplete,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) addArtworkExplainRoute(r chi.Router) {
|
||||||
|
r.Get("/artwork/explain", api.explainArtwork())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) explainArtwork() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
kind, ok := model.ParseKind(r.URL.Query().Get("kind"))
|
||||||
|
if !ok || !slices.Contains(artwork.ExplainKinds, kind) {
|
||||||
|
http.Error(w, "invalid artwork kind", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
|
||||||
|
// No Walk: the endpoint reports history only, so it can never reach the network.
|
||||||
|
rep, err := artwork.Explain(ctx, api.ds, api.agents, kind, id, artwork.ExplainOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(ctx, "Error explaining artwork", "kind", kind, "id", id, err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(toExplainDTO(rep)); err != nil {
|
||||||
|
log.Error(ctx, "Error encoding artwork explain response", "kind", kind, "id", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rfc3339(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStepDTOs(steps []artwork.TraceStep) []traceStepDTO {
|
||||||
|
// Never nil: the UI maps over this unconditionally.
|
||||||
|
out := make([]traceStepDTO, 0, len(steps))
|
||||||
|
for _, s := range steps {
|
||||||
|
out = append(out, traceStepDTO{Candidate: s.Candidate, Outcome: string(s.Outcome), Detail: s.Detail})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toExplainDTO(rep artwork.ExplainReport) explainDTO {
|
||||||
|
dto := explainDTO{
|
||||||
|
Name: rep.Name,
|
||||||
|
Result: rep.Result(),
|
||||||
|
Steps: toStepDTOs(rep.Steps),
|
||||||
|
Agents: rep.Agents,
|
||||||
|
AgentsIncomplete: rep.AgentsIncomplete,
|
||||||
|
}
|
||||||
|
if rep.Stored != nil {
|
||||||
|
dto.Stored = &storedDTO{
|
||||||
|
Source: rep.Stored.Source,
|
||||||
|
SourcePath: rep.Stored.SourcePath,
|
||||||
|
AttemptedAt: rfc3339(rep.Stored.AttemptedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep.Queued != nil {
|
||||||
|
dto.Queued = &queuedDTO{
|
||||||
|
PriorityName: artwork.PriorityName(rep.Queued.Priority),
|
||||||
|
Attempts: rep.Queued.Attempts,
|
||||||
|
RetryAt: rfc3339(rep.Queued.RetryAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if steps := rep.LastAttemptFailed(); len(steps) > 0 {
|
||||||
|
dto.LastAttemptFailed = toStepDTOs(steps)
|
||||||
|
}
|
||||||
|
if steps := rep.GaveUpAfter(); len(steps) > 0 {
|
||||||
|
dto.GaveUpAfter = toStepDTOs(steps)
|
||||||
|
}
|
||||||
|
if setting, value := artwork.ConfigFor(rep.Kind); setting != "" {
|
||||||
|
dto.Config = &configDTO{Setting: setting, Value: value}
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
152
server/nativeapi/artwork_explain_test.go
Normal file
152
server/nativeapi/artwork_explain_test.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("GET /artwork/explain", func() {
|
||||||
|
var router http.Handler
|
||||||
|
var ds *tests.MockDataStore
|
||||||
|
var artRepo *tests.MockArtworkRepo
|
||||||
|
var queueRepo *tests.MockArtworkQueueRepo
|
||||||
|
var adminToken, userToken string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.EnableSharing = false
|
||||||
|
conf.Server.ArtistArtPriority = "external"
|
||||||
|
artRepo = tests.CreateMockArtworkRepo()
|
||||||
|
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||||
|
ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
|
||||||
|
Expect(ds.Artist(context.Background()).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||||
|
auth.Init(ds)
|
||||||
|
|
||||||
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||||
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
|
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
|
||||||
|
regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"}
|
||||||
|
Expect(ds.User(context.Background()).Put(&adminUser)).To(Succeed())
|
||||||
|
Expect(ds.User(context.Background()).Put(®ularUser)).To(Succeed())
|
||||||
|
|
||||||
|
var err error
|
||||||
|
adminToken, err = auth.CreateToken(&adminUser)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
userToken, err = auth.CreateToken(®ularUser)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 403 for a non-admin", func() {
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, userToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 400 for an unknown kind", func() {
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=zz&id=ar-1", nil, adminToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 for an unknown id", func() {
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=missing", nil, adminToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the report for an admin", func() {
|
||||||
|
// Storage shape from core/artwork/trace.go's storedStep: single-letter keys, "d" optional.
|
||||||
|
trace := `[{"c":"external:deezer","o":"hit","d":"https://cdn/x.jpg"}]`
|
||||||
|
gaveUpTrace := `[{"c":"external:deezer","o":"error","d":"connection reset"}]`
|
||||||
|
attemptedAt := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
|
Hash: "abc", Source: "external:deezer", SourcePath: "/music/Radiohead/folder.jpg", Trace: trace,
|
||||||
|
LastFailure: gaveUpTrace, AttemptedAt: attemptedAt,
|
||||||
|
})).To(Succeed())
|
||||||
|
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
|
||||||
|
Expect(got["name"]).To(Equal("Radiohead"))
|
||||||
|
Expect(got["result"]).To(Equal("resolved from external:deezer"))
|
||||||
|
Expect(got["config"]).To(HaveKeyWithValue("setting", "ArtistArtPriority"))
|
||||||
|
|
||||||
|
Expect(got["stored"]).To(Equal(map[string]any{
|
||||||
|
"source": "external:deezer",
|
||||||
|
"sourcePath": "/music/Radiohead/folder.jpg",
|
||||||
|
"attemptedAt": attemptedAt.Format(time.RFC3339),
|
||||||
|
}))
|
||||||
|
Expect(got["steps"]).To(Equal([]any{
|
||||||
|
map[string]any{"candidate": "external:deezer", "outcome": "hit", "detail": "https://cdn/x.jpg"},
|
||||||
|
}))
|
||||||
|
Expect(got["gaveUpAfter"]).To(Equal([]any{
|
||||||
|
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "connection reset"},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the queue state and the last-attempt trace", func() {
|
||||||
|
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
||||||
|
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
|
Priority: model.ArtworkPriorityScan,
|
||||||
|
})).To(Succeed())
|
||||||
|
seen, err := queueRepo.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
retryAt := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
|
||||||
|
failTrace := `[{"c":"external:deezer","o":"error","d":"timeout"}]`
|
||||||
|
Expect(queueRepo.MarkFailedIfUnchanged(model.KindArtistArtwork.Prefix(), "ar-1", model.ImageTypePrimary,
|
||||||
|
seen.RetryAt, retryAt, failTrace)).To(Succeed())
|
||||||
|
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
|
||||||
|
Expect(got["queued"]).To(Equal(map[string]any{
|
||||||
|
"priorityName": "scan",
|
||||||
|
"attempts": float64(1),
|
||||||
|
"retryAt": retryAt.Format(time.RFC3339),
|
||||||
|
}))
|
||||||
|
Expect(got["lastAttemptFailed"]).To(Equal([]any{
|
||||||
|
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "timeout"},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits empty sections", func() {
|
||||||
|
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
|
||||||
|
Expect(got).ToNot(HaveKey("stored"))
|
||||||
|
Expect(got).ToNot(HaveKey("queued"))
|
||||||
|
Expect(got).ToNot(HaveKey("lastAttemptFailed"))
|
||||||
|
Expect(got).ToNot(HaveKey("gaveUpAfter"))
|
||||||
|
Expect(got).ToNot(HaveKey("chainOrigin"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
|
||||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
|
||||||
conf.Server.EnableSharing = false
|
conf.Server.EnableSharing = false
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
|
||||||
}
|
}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
provider = &fakeProvider{}
|
provider = &fakeProvider{}
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
|
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
|
|
@ -48,10 +49,11 @@ type Router struct {
|
||||||
pluginManager PluginManager
|
pluginManager PluginManager
|
||||||
imgUpload artwork.Uploader
|
imgUpload artwork.Uploader
|
||||||
provider external.Provider
|
provider external.Provider
|
||||||
|
agents *agents.Agents
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router {
|
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, ag *agents.Agents) *Router {
|
||||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
|
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, agents: ag}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +97,7 @@ func (api *Router) routes() http.Handler {
|
||||||
api.addUserLibraryRoute(r)
|
api.addUserLibraryRoute(r)
|
||||||
api.addPluginRoute(r)
|
api.addPluginRoute(r)
|
||||||
api.addMetadataRoute(r)
|
api.addMetadataRoute(r)
|
||||||
|
api.addArtworkExplainRoute(r)
|
||||||
api.RX(r, "/library", api.libs.NewRepository, true)
|
api.RX(r, "/library", api.libs.NewRepository, true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||||
mfRepo.SetData(testSongs)
|
mfRepo.SetData(testSongs)
|
||||||
|
|
||||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||||
err := userRepo.Put(&testUser)
|
err := userRepo.Put(&testUser)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
mockManager = &tests.MockPluginManager{}
|
mockManager = &tests.MockPluginManager{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
|
|
||||||
userService := core.NewUser(ds, noopPluginUnloader{})
|
userService := core.NewUser(ds, noopPluginUnloader{})
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,18 @@ func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*mode
|
||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTrace stores a queue row's trace directly, bypassing the retry-token check
|
||||||
|
// MarkFailedIfUnchanged enforces; tests use it to seed a failure trace outright.
|
||||||
|
func (m *MockArtworkQueueRepo) SetTrace(kind model.Kind, id, imageType, trace string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
k := iaKey(kind.Prefix(), id, imageType)
|
||||||
|
if it, ok := m.Data[k]; ok {
|
||||||
|
it.Trace = trace
|
||||||
|
m.Data[k] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,11 @@ export const closeDuplicateSongDialog = () => ({
|
||||||
type: DUPLICATE_SONG_WARNING_CLOSE,
|
type: DUPLICATE_SONG_WARNING_CLOSE,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const openExtendedInfoDialog = (record) => {
|
export const openExtendedInfoDialog = (record, resource) => {
|
||||||
return {
|
return {
|
||||||
type: EXTENDED_INFO_OPEN,
|
type: EXTENDED_INFO_OPEN,
|
||||||
record,
|
record,
|
||||||
|
resource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
|
ArtworkInfo,
|
||||||
MultiLineTextField,
|
MultiLineTextField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
RangeField,
|
RangeField,
|
||||||
|
|
@ -139,6 +140,7 @@ const AlbumInfo = (props) => {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<ParticipantsInfo record={record} classes={classes} />
|
<ParticipantsInfo record={record} classes={classes} />
|
||||||
|
<ArtworkInfo resource="album" id={record.id} />
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
|
||||||
82
ui/src/artist/ArtistInfo.jsx
Normal file
82
ui/src/artist/ArtistInfo.jsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import Table from '@material-ui/core/Table'
|
||||||
|
import TableBody from '@material-ui/core/TableBody'
|
||||||
|
import { humanize, underscore } from 'inflection'
|
||||||
|
import TableCell from '@material-ui/core/TableCell'
|
||||||
|
import TableContainer from '@material-ui/core/TableContainer'
|
||||||
|
import TableRow from '@material-ui/core/TableRow'
|
||||||
|
import {
|
||||||
|
DateField,
|
||||||
|
TextField,
|
||||||
|
useRecordContext,
|
||||||
|
useTranslate,
|
||||||
|
} from 'react-admin'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { ArtworkInfo, SizeField } from '../common'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
tableCell: {
|
||||||
|
width: '17.5%',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ArtistInfo = (props) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const translate = useTranslate()
|
||||||
|
const record = useRecordContext(props)
|
||||||
|
const data = {
|
||||||
|
name: <TextField source={'name'} />,
|
||||||
|
sortArtistName: <TextField source={'sortArtistName'} />,
|
||||||
|
mbzArtistId: <TextField source={'mbzArtistId'} />,
|
||||||
|
albumCount: <TextField source={'albumCount'} />,
|
||||||
|
songCount: <TextField source={'songCount'} />,
|
||||||
|
size: <SizeField source={'size'} />,
|
||||||
|
playCount: <TextField source={'playCount'} />,
|
||||||
|
externalInfoUpdatedAt: (
|
||||||
|
<DateField source={'externalInfoUpdatedAt'} showTime />
|
||||||
|
),
|
||||||
|
updatedAt: <DateField source={'updatedAt'} showTime />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalFields = [
|
||||||
|
'sortArtistName',
|
||||||
|
'mbzArtistId',
|
||||||
|
'externalInfoUpdatedAt',
|
||||||
|
]
|
||||||
|
optionalFields.forEach((field) => {
|
||||||
|
!record[field] && delete data[field]
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<Table aria-label="artist details" size="small">
|
||||||
|
<TableBody>
|
||||||
|
{Object.keys(data).map((key) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={`${record.id}-${key}`}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
className={classes.tableCell}
|
||||||
|
>
|
||||||
|
{translate(`resources.artist.fields.${key}`, {
|
||||||
|
_: humanize(underscore(key)),
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="left" className={classes.value}>
|
||||||
|
{data[key]}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ArtworkInfo resource="artist" id={record.id} />
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtistInfo
|
||||||
39
ui/src/artist/ArtistInfo.test.jsx
Normal file
39
ui/src/artist/ArtistInfo.test.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { RecordContextProvider } from 'react-admin'
|
||||||
|
import ArtistInfo from './ArtistInfo'
|
||||||
|
|
||||||
|
vi.mock('../common', async (importOriginal) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
ArtworkInfo: () => (
|
||||||
|
<tr>
|
||||||
|
<td>artwork-section</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const renderWith = (record) =>
|
||||||
|
render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<ArtistInfo />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('<ArtistInfo />', () => {
|
||||||
|
it('renders the artist fields', () => {
|
||||||
|
renderWith({ id: 'ar-1', name: 'Radiohead', albumCount: 9, songCount: 120 })
|
||||||
|
expect(screen.getByText('Radiohead')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('9')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops optional fields that are empty', () => {
|
||||||
|
renderWith({ id: 'ar-1', name: 'Radiohead' })
|
||||||
|
expect(screen.queryByText(/MusicBrainz/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the artwork section', () => {
|
||||||
|
renderWith({ id: 'ar-1', name: 'Radiohead' })
|
||||||
|
expect(screen.getByText('artwork-section')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -35,6 +35,8 @@ import ArtistSimpleList from './ArtistSimpleList'
|
||||||
import { DraggableTypes } from '../consts'
|
import { DraggableTypes } from '../consts'
|
||||||
import en from '../i18n/en.json'
|
import en from '../i18n/en.json'
|
||||||
import { formatBytes } from '../utils/index.js'
|
import { formatBytes } from '../utils/index.js'
|
||||||
|
import ArtistInfo from './ArtistInfo'
|
||||||
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
contextHeader: {
|
contextHeader: {
|
||||||
|
|
@ -219,6 +221,7 @@ const ArtistList = (props) => {
|
||||||
>
|
>
|
||||||
<ArtistListView {...props} />
|
<ArtistListView {...props} />
|
||||||
</List>
|
</List>
|
||||||
|
<ExpandInfoDialog content={<ArtistInfo />} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
import AlbumInfo from '../album/AlbumInfo'
|
import AlbumInfo from '../album/AlbumInfo'
|
||||||
|
import ArtistInfo from './ArtistInfo'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
import { Artwork } from '../common/Artwork'
|
import { Artwork } from '../common/Artwork'
|
||||||
|
|
@ -160,7 +161,8 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<ExpandInfoDialog content={<AlbumInfo />} />
|
<ExpandInfoDialog resource="album" content={<AlbumInfo />} />
|
||||||
|
<ExpandInfoDialog resource="artist" content={<ArtistInfo />} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
183
ui/src/common/ArtworkInfo.jsx
Normal file
183
ui/src/common/ArtworkInfo.jsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { Chip, Link, TableCell, TableRow } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useDataProvider, usePermissions, useTranslate } from 'react-admin'
|
||||||
|
import { DateField } from './DateField'
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
chip: { color: theme.palette.common.white, height: 20 },
|
||||||
|
hit: { backgroundColor: theme.palette.success.main },
|
||||||
|
error: { backgroundColor: theme.palette.error.main },
|
||||||
|
neutral: { backgroundColor: theme.palette.grey[500] },
|
||||||
|
toggle: { cursor: 'pointer' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const OUTCOME_CLASS = { hit: 'hit', error: 'error', unreadable: 'error' }
|
||||||
|
|
||||||
|
const OutcomeChip = ({ outcome }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={outcome}
|
||||||
|
className={clsx(
|
||||||
|
classes.chip,
|
||||||
|
classes[OUTCOME_CLASS[outcome] || 'neutral'],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutcomeChip.propTypes = {
|
||||||
|
outcome: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTable = ({ title, steps }) => {
|
||||||
|
if (!steps?.length) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<TableRow key={`${s.candidate}-${i}`}>
|
||||||
|
<TableCell>{s.candidate}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OutcomeChip outcome={s.outcome} /> {s.detail}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StepTable.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
steps: PropTypes.array,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Row = ({ label, children }) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell component="th" scope="row">
|
||||||
|
{label}:
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="left">{children}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
|
||||||
|
Row.propTypes = {
|
||||||
|
label: PropTypes.node.isRequired,
|
||||||
|
children: PropTypes.node,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArtworkInfo = ({ resource, id }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const translate = useTranslate()
|
||||||
|
const dataProvider = useDataProvider()
|
||||||
|
const { permissions } = usePermissions()
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const isAdmin = permissions === 'admin'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return undefined
|
||||||
|
let live = true
|
||||||
|
dataProvider
|
||||||
|
.explainArtwork(resource, id)
|
||||||
|
.then(({ data }) => live && setReport(data))
|
||||||
|
.catch(() => live && setReport(null))
|
||||||
|
return () => {
|
||||||
|
live = false
|
||||||
|
}
|
||||||
|
}, [dataProvider, resource, id, isAdmin])
|
||||||
|
|
||||||
|
if (!isAdmin || !report) return null
|
||||||
|
|
||||||
|
const recorded = !!report.stored
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2}>
|
||||||
|
<strong>{translate('artwork.title')}</strong>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<Row label={translate('artwork.result')}>{report.result}</Row>
|
||||||
|
{recorded ? (
|
||||||
|
<>
|
||||||
|
<Row label={translate('artwork.source')}>{report.stored?.source}</Row>
|
||||||
|
<Row label={translate('artwork.attemptedAt')}>
|
||||||
|
<DateField record={report.stored} source="attemptedAt" showTime />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Row label={translate('artwork.source')}>
|
||||||
|
{translate('artwork.notRecorded')}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2}>
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
className={classes.toggle}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{translate(
|
||||||
|
expanded ? 'artwork.hideDetails' : 'artwork.showDetails',
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{expanded && (
|
||||||
|
<>
|
||||||
|
<StepTable title={translate('artwork.chain')} steps={report.steps} />
|
||||||
|
<StepTable
|
||||||
|
title={translate('artwork.lastAttemptFailed')}
|
||||||
|
steps={report.lastAttemptFailed}
|
||||||
|
/>
|
||||||
|
<StepTable
|
||||||
|
title={translate('artwork.gaveUpAfter')}
|
||||||
|
steps={report.gaveUpAfter}
|
||||||
|
/>
|
||||||
|
{report.stored?.sourcePath && (
|
||||||
|
<Row label={translate('artwork.sourcePath')}>
|
||||||
|
{report.stored.sourcePath}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{report.queued && (
|
||||||
|
<>
|
||||||
|
<Row label={translate('artwork.priority')}>
|
||||||
|
{report.queued.priorityName}
|
||||||
|
</Row>
|
||||||
|
<Row label={translate('artwork.attempts')}>
|
||||||
|
{report.queued.attempts}
|
||||||
|
</Row>
|
||||||
|
<Row label={translate('artwork.retryAt')}>
|
||||||
|
<DateField record={report.queued} source="retryAt" showTime />
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{report.config && (
|
||||||
|
<Row label={report.config.setting}>{report.config.value}</Row>
|
||||||
|
)}
|
||||||
|
{report.agents && (
|
||||||
|
<Row label={translate('artwork.agents')}>
|
||||||
|
{report.agents}
|
||||||
|
{report.agentsIncomplete && (
|
||||||
|
<div>{translate('artwork.agentsIncomplete')}</div>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtworkInfo.propTypes = {
|
||||||
|
resource: PropTypes.oneOf(['album', 'artist']).isRequired,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
108
ui/src/common/ArtworkInfo.test.jsx
Normal file
108
ui/src/common/ArtworkInfo.test.jsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ArtworkInfo } from './ArtworkInfo'
|
||||||
|
|
||||||
|
const explainArtwork = vi.fn()
|
||||||
|
const { mockPermissions } = vi.hoisted(() => ({
|
||||||
|
mockPermissions: { value: 'admin' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('react-admin', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useDataProvider: () => ({ explainArtwork }),
|
||||||
|
usePermissions: () => ({ permissions: mockPermissions.value }),
|
||||||
|
useTranslate: () => (key) => key,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
result: 'resolved from external:deezer',
|
||||||
|
chainOrigin: 'recorded 2026-09-01T10:00:00Z',
|
||||||
|
stored: {
|
||||||
|
source: 'external:deezer',
|
||||||
|
sourcePath: '/music/Radiohead/folder.jpg',
|
||||||
|
attemptedAt: '2026-09-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{ candidate: 'cover.*', outcome: 'miss' },
|
||||||
|
{
|
||||||
|
candidate: 'external:deezer',
|
||||||
|
outcome: 'hit',
|
||||||
|
detail: 'https://cdn/x.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queued: { priority: 20, priorityName: 'scan', attempts: 1, retryAt: '' },
|
||||||
|
config: { setting: 'ArtistArtPriority', value: 'external' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtworkInfo renders bare TableRows to slot into the caller's TableBody, so it needs a
|
||||||
|
// table/tbody ancestor or React logs DOM-nesting warnings.
|
||||||
|
const renderInTable = (ui) =>
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>{ui}</tbody>
|
||||||
|
</table>,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('<ArtworkInfo />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPermissions.value = 'admin'
|
||||||
|
explainArtwork.mockReset()
|
||||||
|
explainArtwork.mockResolvedValue({ data: report })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing for a non-admin', () => {
|
||||||
|
mockPermissions.value = 'regular'
|
||||||
|
const { container } = renderInTable(
|
||||||
|
<ArtworkInfo resource="artist" id="ar-1" />,
|
||||||
|
)
|
||||||
|
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
|
||||||
|
expect(explainArtwork).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the summary for an admin', async () => {
|
||||||
|
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
|
||||||
|
expect(
|
||||||
|
await screen.findByText('resolved from external:deezer'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('external:deezer')).not.toBeNull()
|
||||||
|
expect(screen.queryByText('cover.*')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals the step table when details are expanded', async () => {
|
||||||
|
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
|
||||||
|
await screen.findByText('resolved from external:deezer')
|
||||||
|
await userEvent.click(screen.getByText('artwork.showDetails'))
|
||||||
|
expect(screen.getByText('cover.*')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('ArtistArtPriority:')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('/music/Radiohead/folder.jpg')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('scan')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the not-recorded state', async () => {
|
||||||
|
explainArtwork.mockResolvedValue({
|
||||||
|
data: { result: 'not resolved', chainOrigin: 'not recorded', steps: [] },
|
||||||
|
})
|
||||||
|
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
|
||||||
|
expect(await screen.findByText('artwork.notRecorded')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// stored/queued/config/agents are all omitted by the endpoint here, so the optional
|
||||||
|
// blocks' guards must not throw when expanded.
|
||||||
|
await userEvent.click(screen.getByText('artwork.showDetails'))
|
||||||
|
expect(screen.queryByText('artwork.priority')).toBeNull()
|
||||||
|
expect(screen.queryByText('artwork.agents')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when the fetch fails', async () => {
|
||||||
|
explainArtwork.mockRejectedValue(new Error('boom'))
|
||||||
|
const { container } = renderInTable(
|
||||||
|
<ArtworkInfo resource="artist" id="ar-1" />,
|
||||||
|
)
|
||||||
|
await waitFor(() => expect(explainArtwork).toHaveBeenCalled())
|
||||||
|
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -146,9 +146,9 @@ const ContextMenu = ({
|
||||||
...(!hideInfo && {
|
...(!hideInfo && {
|
||||||
info: {
|
info: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
needData: true,
|
needData: false,
|
||||||
label: translate('resources.album.actions.info'),
|
label: translate('resources.album.actions.info'),
|
||||||
action: () => dispatch(openExtendedInfoDialog(record)),
|
action: (record) => dispatch(openExtendedInfoDialog(record, resource)),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +272,6 @@ export const ArtistContextMenu = (props) =>
|
||||||
props.record ? (
|
props.record ? (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
{...props}
|
{...props}
|
||||||
hideInfo={true}
|
|
||||||
resource={'artist'}
|
resource={'artist'}
|
||||||
songQueryParams={{
|
songQueryParams={{
|
||||||
pagination: { page: 1, perPage: 200 },
|
pagination: { page: 1, perPage: 200 },
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { TestContext } from 'ra-test'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||||
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
|
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
|
||||||
|
import { EXTENDED_INFO_OPEN } from '../actions'
|
||||||
|
|
||||||
const mockDispatch = vi.fn()
|
const mockDispatch = vi.fn()
|
||||||
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
|
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
|
||||||
|
|
@ -130,4 +131,28 @@ describe('ContextMenus', () => {
|
||||||
expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1')
|
expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('info dialog', () => {
|
||||||
|
it('dispatches the record and resource when opening the info dialog', () => {
|
||||||
|
const record = { id: 'al1', name: 'Album', songCount: 1 }
|
||||||
|
renderMenu(AlbumContextMenu, record)
|
||||||
|
fireEvent.click(screen.getByText('resources.album.actions.info'))
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
|
type: EXTENDED_INFO_OPEN,
|
||||||
|
record,
|
||||||
|
resource: 'album',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches the record and resource when opening the artist info dialog', () => {
|
||||||
|
const record = { id: 'ar1', name: 'Artist', stats: {} }
|
||||||
|
renderMenu(ArtistContextMenu, record)
|
||||||
|
fireEvent.click(screen.getByText('resources.album.actions.info'))
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
|
type: EXTENDED_INFO_OPEN,
|
||||||
|
record,
|
||||||
|
resource: 'artist',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export const SongContextMenu = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(openExtendedInfoDialog(fullRecord))
|
dispatch(openExtendedInfoDialog(fullRecord, 'song'))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { TestContext } from 'ra-test'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { SongContextMenu } from './SongContextMenu'
|
import { SongContextMenu } from './SongContextMenu'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
|
import { EXTENDED_INFO_OPEN } from '../actions'
|
||||||
|
|
||||||
vi.mock('../dataProvider', () => ({
|
vi.mock('../dataProvider', () => ({
|
||||||
httpClient: vi.fn(),
|
httpClient: vi.fn(),
|
||||||
|
|
@ -130,6 +131,26 @@ describe('SongContextMenu', () => {
|
||||||
expect(mockOnClick).not.toHaveBeenCalled()
|
expect(mockOnClick).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dispatches the record and resource when opening the info dialog', async () => {
|
||||||
|
const record = { id: 'song1', size: 1 }
|
||||||
|
render(
|
||||||
|
<TestContext>
|
||||||
|
<SongContextMenu record={record} resource="song" />
|
||||||
|
</TestContext>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getAllByRole('button')[1])
|
||||||
|
await waitFor(() => screen.getByText(/resources\.song\.actions\.info/))
|
||||||
|
fireEvent.click(screen.getByText(/resources\.song\.actions\.info/))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockDispatch).toHaveBeenCalledWith({
|
||||||
|
type: EXTENDED_INFO_OPEN,
|
||||||
|
record,
|
||||||
|
resource: 'song',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
describe('Instant Mix action', () => {
|
describe('Instant Mix action', () => {
|
||||||
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
|
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
|
||||||
render(
|
render(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './AddToPlaylistButton'
|
export * from './AddToPlaylistButton'
|
||||||
export * from './artist'
|
export * from './artist'
|
||||||
export * from './ArtistLinkField'
|
export * from './ArtistLinkField'
|
||||||
|
export * from './ArtworkInfo'
|
||||||
export * from './BatchPlayButton'
|
export * from './BatchPlayButton'
|
||||||
export * from './BitrateField'
|
export * from './BitrateField'
|
||||||
export * from './CollapsibleComment'
|
export * from './CollapsibleComment'
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { REST_URL } from '../consts'
|
||||||
|
|
||||||
const dataProvider = jsonServerProvider(REST_URL, httpClient)
|
const dataProvider = jsonServerProvider(REST_URL, httpClient)
|
||||||
|
|
||||||
const REFRESH_KIND = { album: 'al', artist: 'ar' }
|
const ARTWORK_KIND = { album: 'al', artist: 'ar' }
|
||||||
|
|
||||||
const isAdmin = () => {
|
const isAdmin = () => {
|
||||||
const role = localStorage.getItem('role')
|
const role = localStorage.getItem('role')
|
||||||
|
|
@ -226,9 +226,13 @@ const wrapperDataProvider = {
|
||||||
// The endpoint answers 204 with no body, but react-admin rejects any response without a
|
// The endpoint answers 204 with no body, but react-admin rejects any response without a
|
||||||
// `data` key, so the id stands in for one.
|
// `data` key, so the id stands in for one.
|
||||||
refreshMetadata: (resource, id) =>
|
refreshMetadata: (resource, id) =>
|
||||||
httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, {
|
httpClient(`${REST_URL}/metadata/${ARTWORK_KIND[resource]}/${id}/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}).then(() => ({ data: { id } })),
|
}).then(() => ({ data: { id } })),
|
||||||
|
explainArtwork: (resource, id) =>
|
||||||
|
httpClient(
|
||||||
|
`${REST_URL}/artwork/explain?kind=${ARTWORK_KIND[resource]}&id=${encodeURIComponent(id)}`,
|
||||||
|
).then(({ json }) => ({ data: json })),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default wrapperDataProvider
|
export default wrapperDataProvider
|
||||||
|
|
|
||||||
|
|
@ -120,4 +120,23 @@ describe('wrapperDataProvider', () => {
|
||||||
).resolves.toEqual({ data: { id: 'al-1' } })
|
).resolves.toEqual({ data: { id: 'al-1' } })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('explainArtwork', () => {
|
||||||
|
it('requests the artist kind prefix', async () => {
|
||||||
|
mockHttpClient.mockResolvedValue({ json: { name: 'Radiohead' } })
|
||||||
|
const result = await wrapperDataProvider.explainArtwork('artist', 'ar-1')
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/artwork/explain?kind=ar&id=ar-1'),
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ data: { name: 'Radiohead' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requests the album kind prefix', async () => {
|
||||||
|
mockHttpClient.mockResolvedValue({ json: {} })
|
||||||
|
await wrapperDataProvider.explainArtwork('album', 'al-1')
|
||||||
|
expect(mockHttpClient).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('kind=al&id=al-1'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,16 @@ import {
|
||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import { closeExtendedInfoDialog } from '../actions'
|
import { closeExtendedInfoDialog } from '../actions'
|
||||||
|
|
||||||
const ExpandInfoDialog = ({ title, content }) => {
|
const ExpandInfoDialog = ({ title, content, resource }) => {
|
||||||
const { open, record } = useSelector((state) => state.expandInfoDialog)
|
const {
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
resource: openFor,
|
||||||
|
} = useSelector((state) => state.expandInfoDialog)
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
|
// One page may mount several of these; each claims the resource it was given.
|
||||||
|
const mine = !resource || resource === openFor
|
||||||
|
|
||||||
const handleClose = (e) => {
|
const handleClose = (e) => {
|
||||||
dispatch(closeExtendedInfoDialog())
|
dispatch(closeExtendedInfoDialog())
|
||||||
|
|
@ -23,7 +29,7 @@ const ExpandInfoDialog = ({ title, content }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open && mine}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
aria-labelledby="info-dialog-album"
|
aria-labelledby="info-dialog-album"
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
|
|
@ -33,7 +39,7 @@ const ExpandInfoDialog = ({ title, content }) => {
|
||||||
{translate(title || 'resources.song.actions.info')}
|
{translate(title || 'resources.song.actions.info')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{record && (
|
{record && mine && (
|
||||||
<RecordContextProvider value={record}>
|
<RecordContextProvider value={record}>
|
||||||
{content}
|
{content}
|
||||||
</RecordContextProvider>
|
</RecordContextProvider>
|
||||||
|
|
@ -50,7 +56,8 @@ const ExpandInfoDialog = ({ title, content }) => {
|
||||||
|
|
||||||
ExpandInfoDialog.propTypes = {
|
ExpandInfoDialog.propTypes = {
|
||||||
title: PropTypes.string,
|
title: PropTypes.string,
|
||||||
content: PropTypes.object.isRequired,
|
content: PropTypes.element.isRequired,
|
||||||
|
resource: PropTypes.string,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ExpandInfoDialog
|
export default ExpandInfoDialog
|
||||||
|
|
|
||||||
54
ui/src/dialogs/ExpandInfoDialog.test.jsx
Normal file
54
ui/src/dialogs/ExpandInfoDialog.test.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
import { TestContext } from 'ra-test'
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react'
|
||||||
|
import { describe, afterEach, it, expect } from 'vitest'
|
||||||
|
import ExpandInfoDialog from './ExpandInfoDialog'
|
||||||
|
|
||||||
|
const renderDialogs = (openFor, dialogs) =>
|
||||||
|
render(
|
||||||
|
<TestContext
|
||||||
|
initialState={{
|
||||||
|
expandInfoDialog: {
|
||||||
|
open: true,
|
||||||
|
record: { id: 'r1', name: 'Record' },
|
||||||
|
resource: openFor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dialogs}
|
||||||
|
</TestContext>,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('ExpandInfoDialog', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
it('renders an unclaimed dialog for any resource', () => {
|
||||||
|
renderDialogs('song', <ExpandInfoDialog content={<div>Song Info</div>} />)
|
||||||
|
expect(screen.getByText('Song Info')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Guards the artist detail page, which mounts both dialogs: an album card's Get Info
|
||||||
|
// must open AlbumInfo, and the page's own ArtistInfo must stay shut.
|
||||||
|
it.each([
|
||||||
|
['artist', 'Artist Info', 'Album Info'],
|
||||||
|
['album', 'Album Info', 'Artist Info'],
|
||||||
|
])('opens only the %s dialog', (openFor, shown, hidden) => {
|
||||||
|
renderDialogs(
|
||||||
|
openFor,
|
||||||
|
<>
|
||||||
|
<ExpandInfoDialog resource="album" content={<div>Album Info</div>} />
|
||||||
|
<ExpandInfoDialog resource="artist" content={<div>Artist Info</div>} />
|
||||||
|
</>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText(shown)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(hidden)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays shut when no mounted dialog claims the resource', () => {
|
||||||
|
renderDialogs(
|
||||||
|
'artist',
|
||||||
|
<ExpandInfoDialog resource="album" content={<div>Album Info</div>} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Album Info')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -110,10 +110,14 @@
|
||||||
"name": "Artist |||| Artists",
|
"name": "Artist |||| Artists",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"sortArtistName": "Sort Name",
|
||||||
|
"mbzArtistId": "MusicBrainz Artist Id",
|
||||||
"albumCount": "Album Count",
|
"albumCount": "Album Count",
|
||||||
"songCount": "Song Count",
|
"songCount": "Song Count",
|
||||||
"size": "Size",
|
"size": "Size",
|
||||||
"playCount": "Plays",
|
"playCount": "Plays",
|
||||||
|
"externalInfoUpdatedAt": "External Info Updated At",
|
||||||
|
"updatedAt": "Updated At",
|
||||||
"rating": "Rating",
|
"rating": "Rating",
|
||||||
"genre": "Genre",
|
"genre": "Genre",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
|
|
@ -646,6 +650,24 @@
|
||||||
"sharedPlaylists": "Shared Playlists",
|
"sharedPlaylists": "Shared Playlists",
|
||||||
"about": "About"
|
"about": "About"
|
||||||
},
|
},
|
||||||
|
"artwork": {
|
||||||
|
"title": "Artwork",
|
||||||
|
"chain": "Chain",
|
||||||
|
"result": "Result",
|
||||||
|
"source": "Source",
|
||||||
|
"attemptedAt": "Attempted at",
|
||||||
|
"sourcePath": "Source path",
|
||||||
|
"showDetails": "Show details",
|
||||||
|
"hideDetails": "Hide details",
|
||||||
|
"priority": "Priority",
|
||||||
|
"attempts": "Attempts",
|
||||||
|
"retryAt": "Retry at",
|
||||||
|
"lastAttemptFailed": "Last attempt failed",
|
||||||
|
"gaveUpAfter": "Gave up after",
|
||||||
|
"agents": "Agents",
|
||||||
|
"agentsIncomplete": "* This agent is configured but cannot supply images",
|
||||||
|
"notRecorded": "No resolution recorded yet"
|
||||||
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"playListsText": "Play Queue",
|
"playListsText": "Play Queue",
|
||||||
"openText": "Open",
|
"openText": "Open",
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ export const expandInfoDialogReducer = (
|
||||||
previousState = {
|
previousState = {
|
||||||
open: false,
|
open: false,
|
||||||
record: undefined,
|
record: undefined,
|
||||||
|
resource: undefined,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -137,12 +138,14 @@ export const expandInfoDialogReducer = (
|
||||||
...previousState,
|
...previousState,
|
||||||
open: true,
|
open: true,
|
||||||
record: payload.record,
|
record: payload.record,
|
||||||
|
resource: payload.resource,
|
||||||
}
|
}
|
||||||
case EXTENDED_INFO_CLOSE:
|
case EXTENDED_INFO_CLOSE:
|
||||||
return {
|
return {
|
||||||
...previousState,
|
...previousState,
|
||||||
open: false,
|
open: false,
|
||||||
record: undefined,
|
record: undefined,
|
||||||
|
resource: undefined,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return previousState
|
return previousState
|
||||||
|
|
|
||||||
45
ui/src/reducers/dialogReducer.test.js
Normal file
45
ui/src/reducers/dialogReducer.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { expandInfoDialogReducer } from './dialogReducer'
|
||||||
|
import { EXTENDED_INFO_OPEN, EXTENDED_INFO_CLOSE } from '../actions'
|
||||||
|
|
||||||
|
describe('expandInfoDialogReducer', () => {
|
||||||
|
it('stores the record and resource on EXTENDED_INFO_OPEN', () => {
|
||||||
|
const record = { id: 'al1', name: 'Album' }
|
||||||
|
const result = expandInfoDialogReducer(
|
||||||
|
{ open: false, record: undefined, resource: undefined },
|
||||||
|
{ type: EXTENDED_INFO_OPEN, record, resource: 'album' },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({ open: true, record, resource: 'album' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the record and resource on EXTENDED_INFO_CLOSE', () => {
|
||||||
|
const previousState = {
|
||||||
|
open: true,
|
||||||
|
record: { id: 'al1', name: 'Album' },
|
||||||
|
resource: 'album',
|
||||||
|
}
|
||||||
|
const result = expandInfoDialogReducer(previousState, {
|
||||||
|
type: EXTENDED_INFO_CLOSE,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
open: false,
|
||||||
|
record: undefined,
|
||||||
|
resource: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns previous state for unknown action', () => {
|
||||||
|
const previousState = {
|
||||||
|
open: false,
|
||||||
|
record: undefined,
|
||||||
|
resource: undefined,
|
||||||
|
}
|
||||||
|
const result = expandInfoDialogReducer(previousState, {
|
||||||
|
type: 'UNKNOWN_ACTION',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(previousState)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue