mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 19:07:12 +02:00
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.
146 lines
4.4 KiB
Go
146 lines
4.4 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"
|
|
)
|
|
|
|
// explainableKinds is every kind the endpoint accepts, matching the CLI's explainKinds: a kind
|
|
// with no chain to walk still has stored state and config to report.
|
|
var explainableKinds = []model.Kind{
|
|
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
|
|
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
|
|
}
|
|
|
|
type traceStepDTO struct {
|
|
Candidate string `json:"candidate"`
|
|
Outcome string `json:"outcome"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
type storedDTO struct {
|
|
Source string `json:"source"`
|
|
Hash string `json:"hash,omitempty"`
|
|
SourcePath string `json:"sourcePath,omitempty"`
|
|
AttemptedAt string `json:"attemptedAt,omitempty"`
|
|
}
|
|
|
|
type queuedDTO struct {
|
|
Priority int `json:"priority"`
|
|
Attempts int `json:"attempts"`
|
|
RetryAt string `json:"retryAt,omitempty"`
|
|
}
|
|
|
|
type configDTO struct {
|
|
Setting string `json:"setting"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type explainDTO struct {
|
|
Kind string `json:"kind"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Result string `json:"result"`
|
|
ChainOrigin string `json:"chainOrigin"`
|
|
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"`
|
|
}
|
|
|
|
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(explainableKinds, 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{
|
|
Kind: rep.Kind.Prefix(),
|
|
ID: rep.ID,
|
|
Name: rep.Name,
|
|
Result: rep.Result(),
|
|
ChainOrigin: rep.ChainOrigin(),
|
|
Steps: toStepDTOs(rep.Steps),
|
|
Agents: rep.Agents,
|
|
}
|
|
if rep.Stored != nil {
|
|
dto.Stored = &storedDTO{
|
|
Source: rep.Stored.Source,
|
|
Hash: rep.Stored.Hash,
|
|
SourcePath: rep.Stored.SourcePath,
|
|
AttemptedAt: rfc3339(rep.Stored.AttemptedAt),
|
|
}
|
|
}
|
|
if rep.Queued != nil {
|
|
dto.Queued = &queuedDTO{
|
|
Priority: 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
|
|
}
|