Compare commits

..
Author SHA1 Message Date
Deluan Quintão 71a1b3b5bd Merge branch 'master' into artwork-explain-ui 2026-09-06 23:50:57 -04:00
Deluan 8ba8e7f2d7 refactor: share the explain kind list and time format, trim the DTO
The explainable-kind list and the report's time format each existed twice,
kept in sync by a comment pointing at the other copy. Both now live in
core/artwork beside their siblings.

FormatAgents returned a CLI-worded sentence, so the web UI rendered starred
agents with no legend at all. It now reports a bool and each caller words its
own note.

Drop kind, id, chainOrigin and the numeric priority from the explain
response: all four were serialized and never read, and chainOrigin is
derivable from stored.attemptedAt on an endpoint that never walks.

ExpandInfoDialog takes an optional resource instead of a node-or-map content
prop. A page mounts one dialog per resource, which removes the silent
blank-dialog failure mode when a map was missing a key.

Outcome chips read the MUI palette rather than hardcoded hex, so they follow
the dark theme.
2026-09-04 07:29:54 -04:00
Deluan f283da0630 fix(artwork): surface priority names in the explain UI and tidy up loose ends
Moves the CLI's priority-name lookup into core/artwork so the native API and
UI can render "scan" instead of a bare priority number, replaces a brittle
string comparison with a presence check on the stored artwork, renders the
source path in the details section, drops the unused hash field, and aligns
date rendering with the rest of the dialog.
2026-09-04 00:11:54 -04:00
Deluan fd46e594e1 feat(ui): add a Get Info dialog for artists 2026-09-03 23:55:40 -04:00
Deluan 687646809f refactor(ui): let the info dialog pick its content by resource 2026-09-03 23:46:06 -04:00
Deluan eadbb442a8 fix(ui): format artwork files, drop unused i18n keys, harden test assertions 2026-09-03 23:38:15 -04:00
Deluan e7f7e7bde9 feat(ui): show the artwork resolution trace in the album info dialog 2026-09-03 23:33:14 -04:00
Deluan d17ef212ab feat(ui): add an explainArtwork data provider method 2026-09-03 23:30:39 -04:00
Deluan 0fcce273a2 test(nativeapi): cover gaveUpAfter and its omission
GaveUpAfter decodes ItemArtwork.LastFailure, a separate path from
LastAttemptFailed's queue-row trace; nothing previously exercised it.
2026-09-03 23:27:41 -04:00
Deluan 395f2f9ae5 test(nativeapi): pin the artwork explain wire format
Extend the admin report spec to assert the full stored object and step
fields, and add a spec covering the queued object and lastAttemptFailed,
so a wrong JSON tag in toExplainDTO fails a test instead of shipping.
2026-09-03 23:25:01 -04:00
Deluan b8c7f3cd2b feat(nativeapi): add an admin-only artwork explain endpoint
Exposes core/artwork.Explain over GET /api/artwork/explain?kind=&id=,
reporting stored trace and queue state without ever walking the chain
live, so the response can only leak history the server already has.
2026-09-03 23:19:49 -04:00
Deluan 03743a030b fix(artwork): set Walked before the Explainable gate, fix zero-time ChainOrigin
Explain now sets rep.Walked ahead of the !Explainable early return, matching
the pre-refactor CLI's unconditional flag (e.g. --live on a playlist), and
ChainOrigin renders a zero AttemptedAt as "-" like the CLI's formatTime did.
2026-09-03 23:10:46 -04:00
Deluan 0ab549e1b2 refactor(artwork): build the explain report in core/artwork
Moves report construction (stored state, queue row, chain walk or recorded
trace) out of the CLI and into artwork.Explain, so a future HTTP handler can
reuse it. cmd/artwork.go keeps only text formatting.
2026-09-03 23:01:29 -04:00
Deluan 3290759e62 refactor(artwork): move the explain verdict helpers into core/artwork 2026-09-03 22:48:23 -04:00
45 changed files with 1517 additions and 528 deletions

No files matched your search

+1 -1
View File
@@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
# otherwise the deleted bytes still ship in the image.
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
+65 -211
View File
@@ -8,9 +8,7 @@ import (
"io"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"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",
Long: "Explain why an item's artwork resolved the way it did.\n\n" +
"The item can be given as a bare id, a full artwork id (e.g. al-<id>), or a <kind> <id> pair.\n" +
"<kind> is one of: " + kindPrefixes(explainKinds) + ".\n" +
"<kind> is one of: " + kindPrefixes(artwork.ExplainKinds) + ".\n" +
"A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2",
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
@@ -264,7 +262,7 @@ func configState(rep statusReport) string {
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
for _, s := range stats {
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count)
fmt.Fprintf(w, "%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)
}
@@ -276,37 +274,14 @@ func kindName(prefix string) string {
return prefix
}
type artworkPriority struct {
name string
value int
}
// knownPriorities is the one listing behind both the name and the parse, so they cannot drift.
var knownPriorities = []artworkPriority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"recheck", model.ArtworkPriorityRecheck},
{"backfill", model.ArtworkPriorityBackfill},
}
// priorityName falls back to the number: a row written by a newer version still has to print.
func priorityName(p int) string {
for _, ap := range knownPriorities {
if ap.value == p {
return ap.name
}
}
return strconv.Itoa(p)
}
func priorityNames() string {
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
return strings.Join(slice.Map(artwork.KnownPriorities, func(ap artwork.Priority) string { return ap.Name }), ", ")
}
func parseArtworkPriority(s string) (int, error) {
for _, ap := range knownPriorities {
if ap.name == s {
return ap.value, nil
for _, ap := range artwork.KnownPriorities {
if ap.Name == s {
return ap.Value, nil
}
}
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
@@ -678,13 +653,6 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo
return failed
}
// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind
// with no chain to walk still has something to answer with.
var explainKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
func kindPrefixes(kinds []model.Kind) string {
return strings.Join(model.KindPrefixes(kinds), ", ")
}
@@ -746,106 +714,15 @@ func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (mode
return model.ArtworkID{Kind: kind, ID: arg}, nil
}
// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a
// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it.
func explainAgents(configured string, available []string) string {
if strings.TrimSpace(configured) == "" {
return "(none)"
}
var unavailable bool
names := slice.Map(strings.Split(configured, ","), func(name string) string {
name = strings.TrimSpace(name)
if slices.Contains(available, name) {
return name
}
unavailable = true
return name + "*"
})
line := strings.Join(names, ", ")
if unavailable {
line += " (* not available to the CLI)"
}
return line
}
// cliUnavailableNote marks agents the CLI cannot construct; a running server loads them all.
const cliUnavailableNote = " (* not available to the CLI)"
// availableImageAgents names the agents that can actually supply an image for kind.
func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string {
ag := agents.GetAgents(ds, mgr)
if kind == model.KindArtistArtwork {
return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name })
// cliAgents words the CLI's own legend for the starred agents FormatAgents reports.
func cliAgents(rep artwork.ExplainReport) string {
if rep.AgentsIncomplete {
return rep.Agents + cliUnavailableNote
}
return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name })
}
// explainResult states the verdict of the walk. A skipped or failed external tier, or a local
// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork.
func explainResult(source string, steps []artwork.TraceStep) string {
if source != "" {
for _, s := range steps {
if s.Outcome == artwork.OutcomeHit {
break
}
// An external winner discards the earlier error, so the resolver settles it with no retry.
if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) &&
!strings.HasPrefix(source, artwork.ExternalPrefix) {
return "resolved from " + source +
" (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)"
}
}
return "resolved from " + source
}
for _, s := range steps {
switch {
case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix):
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
// A stage error or an unreadable candidate means a source was found but not processed; the
// worker retries rather than settling absent, so neither reads as a clean miss.
case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable:
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
}
}
return "not resolved"
}
// explainConfig names the setting that decides where a kind's artwork comes from, and its value.
func explainConfig(kind model.Kind) (name, value string) {
switch kind {
case model.KindArtistArtwork:
return "ArtistArtPriority", conf.Server.ArtistArtPriority
case model.KindAlbumArtwork:
return "CoverArtPriority", conf.Server.CoverArtPriority
case model.KindDiscArtwork:
return "DiscArtPriority", conf.Server.DiscArtPriority
case model.KindMediaFileArtwork:
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
}
return "", ""
}
type explainReport struct {
kind model.Kind
id string
name string
stored *model.ItemArtwork
queued *model.ArtworkQueueItem
agents string
// steps is the chain walk: recorded when the item was resolved, or performed just now when walked.
steps []artwork.TraceStep
source string
walked bool
resolveErr error
}
// explainChainOrigin says whether the operator is reading history or a walk performed just now,
// since the two can disagree after a config change.
func explainChainOrigin(rep explainReport) string {
if rep.walked {
return "walked now"
}
if rep.stored != nil {
return "recorded " + formatTime(rep.stored.AttemptedAt)
}
return "not recorded"
return rep.Agents
}
// 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)
}
func formatExplain(rep explainReport) string {
func formatExplain(rep artwork.ExplainReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
explainable := artwork.Explainable(rep.kind)
stateful := artwork.KeepsState(rep.kind)
unrecorded := !rep.walked && rep.stored == nil
explainable := artwork.Explainable(rep.Kind)
stateful := artwork.KeepsState(rep.Kind)
unrecorded := !rep.Walked && rep.Stored == nil
fmt.Fprintln(w, "Item")
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
fmt.Fprintf(w, " ID:\t%s\n", rep.id)
fmt.Fprintf(w, " Name:\t%s\n", rep.name)
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.Kind, rep.Kind.Prefix())
fmt.Fprintf(w, " ID:\t%s\n", rep.ID)
fmt.Fprintf(w, " Name:\t%s\n", rep.Name)
fmt.Fprintln(w, "\nStored")
switch {
case !stateful:
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind)
case rep.stored == nil:
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.Kind)
case rep.Stored == nil:
fmt.Fprintln(w, " (no artwork state recorded)")
default:
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source))
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)"))
if rep.stored.SourcePath != "" {
fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath)
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.Stored.Source))
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.Stored.Hash, "(absent)"))
if rep.Stored.SourcePath != "" {
fmt.Fprintf(w, " Source path:\t%s\n", rep.Stored.SourcePath)
}
fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt))
fmt.Fprintf(w, " Attempted at:\t%s\n", artwork.FormatTime(rep.Stored.AttemptedAt))
}
fmt.Fprintln(w, "\nQueue")
switch {
case !stateful:
fmt.Fprintln(w, " (never queued)")
case rep.queued == nil:
case rep.Queued == nil:
fmt.Fprintln(w, " (not queued)")
default:
fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority)
fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
fmt.Fprintf(w, " Priority:\t%s (%d)\n", artwork.PriorityName(rep.Queued.Priority), rep.Queued.Priority)
fmt.Fprintf(w, " Attempts:\t%d\n", rep.Queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", artwork.FormatTime(rep.Queued.RetryAt))
}
if rep.queued != nil {
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
if rep.Queued != nil {
writeStepTable(w, "Last attempt failed", rep.LastAttemptFailed())
}
if rep.stored != nil {
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
if rep.Stored != nil {
writeStepTable(w, "Gave up after", rep.GaveUpAfter())
}
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)")
} else {
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
if rep.agents != "" {
fmt.Fprintf(w, " Agents:\t%s\n", rep.agents)
if 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 {
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:
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
// found an image always records its winning candidate.
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
case !rep.walked && len(rep.steps) == 0:
case !rep.Walked && len(rep.Steps) == 0:
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
default:
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
writeSteps(w, " ", rep.steps)
writeSteps(w, " ", rep.Steps)
}
fmt.Fprintln(w, "\nResult")
switch {
case rep.resolveErr != nil:
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
case rep.ResolveErr != nil:
fmt.Fprintf(w, " resolution failed: %s\n", rep.ResolveErr)
case !explainable:
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
case unrecorded:
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
default:
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
fmt.Fprintf(w, " %s\n", rep.Result())
}
w.Flush()
return sb.String()
}
func formatTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format(time.RFC3339)
}
func runExplain(ctx context.Context, args []string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.ExplainKinds)
if err != nil {
log.Fatal(ctx, err)
}
@@ -978,45 +848,29 @@ func runExplain(ctx context.Context, args []string) {
}
kind, id := targets[0].Kind, targets[0].ID
name, err := artwork.ItemName(ctx, ds, kind, id)
if err != nil {
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
var opts artwork.ExplainOptions
// Only artist and album reach an agent, and the load must precede the resolver, which reads the
// same manager. Leaving ag nil elsewhere avoids handing agents.GetAgents a not-yet-loaded manager.
var ag *agents.Agents
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
mgr := loadPluginAgents(ctx, explainLive)
defer func() { _ = mgr.Stop() }()
ag = agents.GetAgents(ds, mgr)
}
rep := explainReport{kind: kind, id: id, name: name}
if artwork.KeepsState(kind) {
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
}
rep.queued, err = ds.ArtworkQueue(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)
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
if explainLive || !artwork.KeepsState(kind) {
opts.Walk = func(t *artwork.ChainTrace) *artwork.TracingResolver {
return CreateArtworkResolver(t, explainLive)
}
}
rep, err := artwork.Explain(ctx, ds, ag, kind, id, opts)
if err != nil {
log.Fatal(ctx, "Failed to explain artwork", "kind", kind, "id", id, err)
}
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
rep.walked = explainLive || !artwork.KeepsState(kind)
if artwork.Explainable(kind) {
// Only artist and album reach an agent, and the load must precede the resolver, which reads
// the same manager.
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
mgr := loadPluginAgents(ctx, explainLive)
defer func() { _ = mgr.Stop() }()
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
}
switch {
case rep.walked:
trace := &artwork.ChainTrace{}
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
rep.steps = trace.Steps()
case rep.stored != nil:
rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath)
rep.source = rep.stored.Source
}
}
fmt.Print(formatExplain(rep))
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
if rep.resolveErr != nil {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr)
if rep.ResolveErr != nil {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.ResolveErr)
}
}
+56 -165
View File
@@ -41,8 +41,8 @@ var _ = Describe("parseArtworkKind", func() {
_, err := parseArtworkKind(prefix, valid)
Expect(err).ToNot(HaveOccurred())
},
Entry("explain reads disc artwork", "dc", explainKinds),
Entry("explain reads media file artwork", "mf", explainKinds),
Entry("explain reads disc artwork", "dc", artwork.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
// accept it: the queue row would be rejected on every drain.
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() {
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(failures).To(BeEmpty())
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() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, artwork.ExplainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(failures).To(BeEmpty())
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() {
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds)
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, artwork.ExplainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
})
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(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() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, artwork.ExplainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(BeEmpty())
Expect(failures).To(HaveLen(1))
@@ -113,7 +113,7 @@ var _ = Describe("resolveArtworkTargets", 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(targets).To(Equal([]model.ArtworkID{
{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 rep explainReport
var rep artwork.ExplainReport
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external, artist.*"
rep = explainReport{
kind: model.KindArtistArtwork,
id: "ar-1",
name: "Radiohead",
agents: "lastfm,spotify",
walked: true,
steps: []artwork.TraceStep{
rep = artwork.ExplainReport{
Kind: model.KindArtistArtwork,
ID: "ar-1",
Name: "Radiohead",
Agents: "lastfm,spotify",
Walked: true,
Steps: []artwork.TraceStep{
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
{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() {
out := formatExplain(rep)
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() {
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}
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
rep.Queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
RetryAt: attempted.Add(time.Hour)}
rep.source = "folder"
rep.Source = "folder"
out := formatExplain(rep)
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() {
rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()}
rep.Stored = &model.ItemArtwork{AttemptedAt: time.Now()}
Expect(formatExplain(rep)).To(ContainSubstring("absent"))
})
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)
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() {
conf.Server.CoverArtPriority = "cover.*, embedded"
rep.kind = model.KindPlaylistArtwork
rep.steps = nil
rep.agents = ""
rep.Kind = model.KindPlaylistArtwork
rep.Steps = nil
rep.Agents = ""
out := formatExplain(rep)
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() {
conf.Server.DiscArtPriority = "cover.jpg, embedded"
rep = explainReport{
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
source: "folder",
walked: true,
rep = artwork.ExplainReport{
Kind: model.KindDiscArtwork, ID: "al-1:2", Name: "OK Computer (disc 2)",
Steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
Source: "folder",
Walked: true,
}
out := formatExplain(rep)
@@ -325,15 +235,15 @@ var _ = Describe("formatExplain", func() {
Context("stored traces", func() {
BeforeEach(func() {
rep.walked = false
rep.steps = nil
rep.Walked = false
rep.Steps = nil
})
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)
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
rep.source = "folder"
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
rep.Steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
rep.Source = "folder"
out := formatExplain(rep)
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() {
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"))
})
@@ -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() {
// 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.
rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
rep.Stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
out := formatExplain(rep)
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() {
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"}]`}
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"}]`}
out := formatExplain(rep)
@@ -388,10 +298,10 @@ var _ = Describe("formatExplain", func() {
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
walked: true,
steps: []artwork.TraceStep{
rep = artwork.ExplainReport{
Kind: model.KindMediaFileArtwork, ID: "mf-1", Name: "Airbag",
Walked: true,
Steps: []artwork.TraceStep{
{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() {
It("requires at least one argument", func() {
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(),
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
shown := storedSource(formatExplain(artwork.ExplainReport{Kind: model.KindArtistArtwork, ID: "ar-1",
Stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
q := ds.ArtworkQueue(ctx)
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
@@ -1059,7 +950,7 @@ var _ = Describe("parseArtworkPriority", func() {
It("accepts every name status prints", func() {
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
Expect(parseArtworkPriority(artwork.PriorityName(p))).To(Equal(p))
}
})
+1 -1
View File
@@ -79,7 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
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
}
+215
View 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
View 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())
})
})
+7
View File
@@ -25,6 +25,13 @@ var ReprocessKinds = []model.Kind{
// 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 }
// 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
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
// resolves them, it just never enumerates them in bulk.
+1 -1
View File
@@ -1 +1 @@
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 .
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
+2 -2
View File
@@ -34,8 +34,8 @@ func imageSize(maxWidth, maxHeight int) int {
}
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
// Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and
// upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user).
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
itemId, ok := itemIDParam(w, r, "itemId")
if !ok {
+133
View 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
View 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(&regularUser)).To(Succeed())
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
userToken, err = auth.CreateToken(&regularUser)
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"))
})
})
+1 -1
View File
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
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)
// Create test users
+1 -1
View File
@@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
conf.Server.EnableSharing = false
ds = &tests.MockDataStore{}
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)
// Create test users
+1 -1
View File
@@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
}
auth.Init(ds)
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)
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
+5 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
@@ -48,10 +49,11 @@ type Router struct {
pluginManager PluginManager
imgUpload artwork.Uploader
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 {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
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, agents: ag}
r.Handler = r.routes()
return r
}
@@ -95,6 +97,7 @@ func (api *Router) routes() http.Handler {
api.addUserLibraryRoute(r)
api.addPluginRoute(r)
api.addMetadataRoute(r)
api.addArtworkExplainRoute(r)
api.RX(r, "/library", api.libs.NewRepository, true)
})
})
+1 -1
View File
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// 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)
w = httptest.NewRecorder()
})
+1 -1
View File
@@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
err := userRepo.Put(&testUser)
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)
w = httptest.NewRecorder()
})
+1 -1
View File
@@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
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)
// Create test users
@@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
auth.Init(ds)
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)
})
@@ -205,76 +205,3 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
})
})
var _ = Describe("Sharing Downloadable Default", func() {
var albumID string
BeforeEach(func() {
conf.Server.EnableSharing = true
setupTestDB()
conf.Server.EnableDownloads = true
albumID = albumIDByName("Abbey Road")
})
createShare := func(params ...string) *model.Share {
GinkgoHelper()
resp := doReq("createShare", append([]string{"id", albumID}, params...)...)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares.Share).To(HaveLen(1))
share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID)
Expect(err).ToNot(HaveOccurred())
return share
}
DescribeTable("createShare resolves downloadable",
func(defaultDownloadable, enableDownloads bool, params []string, expected bool) {
conf.Server.DefaultDownloadableShare = defaultDownloadable
conf.Server.EnableDownloads = enableDownloads
Expect(createShare(params...).Downloadable).To(Equal(expected))
},
Entry("applies the default when the param is absent", true, true, nil, true),
Entry("stays off when the default is off", false, true, nil, false),
Entry("ignores the default when downloads are disabled", true, false, nil, false),
Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false),
Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true),
)
It("updateShare keeps the current downloadable when the param is absent", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare()
Expect(share.Downloadable).To(BeTrue())
resp := doReq("updateShare", "id", share.ID, "description", "Updated")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(Equal("Updated"))
Expect(updated.Downloadable).To(BeTrue())
})
It("updateShare applies an explicit downloadable and keeps the description", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare("description", "Keep me")
resp := doReq("updateShare", "id", share.ID, "downloadable", "false")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Downloadable).To(BeFalse())
Expect(updated.Description).To(Equal("Keep me"))
})
It("updateShare clears the description when it is sent empty", func() {
share := createShare("description", "Clear me")
resp := doReq("updateShare", "id", share.ID, "description", "")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(BeEmpty())
})
})
+7 -25
View File
@@ -1,13 +1,11 @@
package subsonic
import (
"cmp"
"net/http"
"strings"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -62,10 +60,9 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
id, err := repo.(rest.Persistable).Save(share)
@@ -90,27 +87,12 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
// The update always writes description and downloadable, so read back the
// stored value for whichever one the client omitted.
description := p.StringPtr("description")
downloadable := p.BoolPtr("downloadable")
if description == nil || downloadable == nil {
current, err := repo.Read(id)
if err != nil {
return nil, err
}
cur := current.(*model.Share)
description = cmp.Or(description, &cur.Description)
downloadable = cmp.Or(downloadable, &cur.Downloadable)
}
share := &model.Share{
ID: id,
Description: *description,
Downloadable: *downloadable,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ID: id,
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
+12
View File
@@ -39,6 +39,18 @@ func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*mode
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 {
m.mu.Lock()
defer m.mu.Unlock()
+2 -1
View File
@@ -60,10 +60,11 @@ export const closeDuplicateSongDialog = () => ({
type: DUPLICATE_SONG_WARNING_CLOSE,
})
export const openExtendedInfoDialog = (record) => {
export const openExtendedInfoDialog = (record, resource) => {
return {
type: EXTENDED_INFO_OPEN,
record,
resource,
}
}
+2
View File
@@ -18,6 +18,7 @@ import {
import { makeStyles } from '@material-ui/core/styles'
import {
ArtistLinkField,
ArtworkInfo,
MultiLineTextField,
ParticipantsInfo,
RangeField,
@@ -139,6 +140,7 @@ const AlbumInfo = (props) => {
)
})}
<ParticipantsInfo record={record} classes={classes} />
<ArtworkInfo resource="album" id={record.id} />
</TableBody>
</Table>
</TableContainer>
+82
View 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
View 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()
})
})
+3
View File
@@ -35,6 +35,8 @@ import ArtistSimpleList from './ArtistSimpleList'
import { DraggableTypes } from '../consts'
import en from '../i18n/en.json'
import { formatBytes } from '../utils/index.js'
import ArtistInfo from './ArtistInfo'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
const useStyles = makeStyles({
contextHeader: {
@@ -219,6 +221,7 @@ const ArtistList = (props) => {
>
<ArtistListView {...props} />
</List>
<ExpandInfoDialog content={<ArtistInfo />} />
</>
)
}
+3 -1
View File
@@ -9,6 +9,7 @@ import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import ArtistInfo from './ArtistInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import { Artwork } from '../common/Artwork'
@@ -160,7 +161,8 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
/>
)}
</Card>
<ExpandInfoDialog content={<AlbumInfo />} />
<ExpandInfoDialog resource="album" content={<AlbumInfo />} />
<ExpandInfoDialog resource="artist" content={<ArtistInfo />} />
</div>
)
}
+183
View 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
View 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()
})
})
+2 -3
View File
@@ -146,9 +146,9 @@ const ContextMenu = ({
...(!hideInfo && {
info: {
enabled: true,
needData: true,
needData: false,
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 ? (
<ContextMenu
{...props}
hideInfo={true}
resource={'artist'}
songQueryParams={{
pagination: { page: 1, perPage: 200 },
+25
View File
@@ -4,6 +4,7 @@ import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
import { EXTENDED_INFO_OPEN } from '../actions'
const mockDispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
@@ -130,4 +131,28 @@ describe('ContextMenus', () => {
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',
})
})
})
})
+1 -1
View File
@@ -165,7 +165,7 @@ export const SongContextMenu = ({
}
}
dispatch(openExtendedInfoDialog(fullRecord))
dispatch(openExtendedInfoDialog(fullRecord, 'song'))
},
},
}
+21
View File
@@ -4,6 +4,7 @@ import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SongContextMenu } from './SongContextMenu'
import subsonic from '../subsonic'
import { EXTENDED_INFO_OPEN } from '../actions'
vi.mock('../dataProvider', () => ({
httpClient: vi.fn(),
@@ -130,6 +131,26 @@ describe('SongContextMenu', () => {
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', () => {
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
render(
+1
View File
@@ -1,6 +1,7 @@
export * from './AddToPlaylistButton'
export * from './artist'
export * from './ArtistLinkField'
export * from './ArtworkInfo'
export * from './BatchPlayButton'
export * from './BitrateField'
export * from './CollapsibleComment'
+6 -2
View File
@@ -4,7 +4,7 @@ import { REST_URL } from '../consts'
const dataProvider = jsonServerProvider(REST_URL, httpClient)
const REFRESH_KIND = { album: 'al', artist: 'ar' }
const ARTWORK_KIND = { album: 'al', artist: 'ar' }
const isAdmin = () => {
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
// `data` key, so the id stands in for one.
refreshMetadata: (resource, id) =>
httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, {
httpClient(`${REST_URL}/metadata/${ARTWORK_KIND[resource]}/${id}/refresh`, {
method: 'POST',
}).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
@@ -120,4 +120,23 @@ describe('wrapperDataProvider', () => {
).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'),
)
})
})
})
+12 -5
View File
@@ -11,10 +11,16 @@ import {
} from '@material-ui/core'
import { closeExtendedInfoDialog } from '../actions'
const ExpandInfoDialog = ({ title, content }) => {
const { open, record } = useSelector((state) => state.expandInfoDialog)
const ExpandInfoDialog = ({ title, content, resource }) => {
const {
open,
record,
resource: openFor,
} = useSelector((state) => state.expandInfoDialog)
const dispatch = useDispatch()
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) => {
dispatch(closeExtendedInfoDialog())
@@ -23,7 +29,7 @@ const ExpandInfoDialog = ({ title, content }) => {
return (
<Dialog
open={open}
open={open && mine}
onClose={handleClose}
aria-labelledby="info-dialog-album"
fullWidth={true}
@@ -33,7 +39,7 @@ const ExpandInfoDialog = ({ title, content }) => {
{translate(title || 'resources.song.actions.info')}
</DialogTitle>
<DialogContent>
{record && (
{record && mine && (
<RecordContextProvider value={record}>
{content}
</RecordContextProvider>
@@ -50,7 +56,8 @@ const ExpandInfoDialog = ({ title, content }) => {
ExpandInfoDialog.propTypes = {
title: PropTypes.string,
content: PropTypes.object.isRequired,
content: PropTypes.element.isRequired,
resource: PropTypes.string,
}
export default ExpandInfoDialog
+54
View 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()
})
})
+22
View File
@@ -110,10 +110,14 @@
"name": "Artist |||| Artists",
"fields": {
"name": "Name",
"sortArtistName": "Sort Name",
"mbzArtistId": "MusicBrainz Artist Id",
"albumCount": "Album Count",
"songCount": "Song Count",
"size": "Size",
"playCount": "Plays",
"externalInfoUpdatedAt": "External Info Updated At",
"updatedAt": "Updated At",
"rating": "Rating",
"genre": "Genre",
"role": "Role",
@@ -646,6 +650,24 @@
"sharedPlaylists": "Shared Playlists",
"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": {
"playListsText": "Play Queue",
"openText": "Open",
+3 -3
View File
@@ -8,8 +8,8 @@ import {
useUnselectAll,
} from 'react-admin'
import { useSelector } from 'react-redux'
import { GiMagnifyingGlass } from 'react-icons/gi'
import { VscSync } from 'react-icons/vsc'
import SyncIcon from '@material-ui/icons/Sync'
import CachedIcon from '@material-ui/icons/Cached'
import subsonic from '../subsonic'
const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
@@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
? translate('resources.library.actions.fullScan')
: translate('resources.library.actions.quickScan')
const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync />
const icon = fullScan ? <CachedIcon /> : <SyncIcon />
return (
<Button
+3
View File
@@ -127,6 +127,7 @@ export const expandInfoDialogReducer = (
previousState = {
open: false,
record: undefined,
resource: undefined,
},
payload,
) => {
@@ -137,12 +138,14 @@ export const expandInfoDialogReducer = (
...previousState,
open: true,
record: payload.record,
resource: payload.resource,
}
case EXTENDED_INFO_CLOSE:
return {
...previousState,
open: false,
record: undefined,
resource: undefined,
}
default:
return previousState
+45
View 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)
})
})
+4 -2
View File
@@ -598,9 +598,11 @@ const NautilineTheme = {
},
},
NDAlbumGridView: {
link: {
albumContainer: {
borderRadius: radii.md,
overflow: 'hidden',
'& img': {
borderRadius: radii.md,
},
},
albumTitle: {
fontWeight: 600,
-22
View File
@@ -12,25 +12,3 @@ describe('NDPlaylistDetails styles', () => {
},
)
})
describe('NDAlbumGridView styles', () => {
const themeEntries = Object.entries(themes)
// The hover overlay is a sibling of the image, so it keeps square corners.
it.each(themeEntries)(
'%s should not round the grid cover image on its own',
(themeName, theme) => {
const container = theme.overrides?.NDAlbumGridView?.albumContainer
expect(container?.['& img']?.borderRadius).toBeUndefined()
},
)
it.each(themeEntries)(
'%s should clip the grid cover link when it is rounded',
(themeName, theme) => {
const link = theme.overrides?.NDAlbumGridView?.link
if (!link?.borderRadius) return
expect(link.overflow).toBe('hidden')
},
)
})