Files
navidrome/server/nativeapi/artwork_explain.go
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

134 lines
3.9 KiB
Go

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
}