Files
minstrel/internal/api/admin_cover_sources.go
bvandeusen 58766dbebe fix(server/api): preserve 500-class messages via apierror.InternalMsg
The PR1-T2 admin handler migration replaced bespoke 500-class messages
("lookup failed", "refetch failed", "trigger failed", "bump failed",
"remove failed", "update failed", "schedule row missing", "re-read
failed", "read failed") with apierror.Internal(err), which forces the
wire Message to "internal server error". That violates the PR1
contract that errEnvelope{Code, Message} must remain byte-identical
for existing clients.

Add apierror.InternalMsg(message, cause) — a 500-class constructor
that preserves the call site's user-facing message while keeping the
cause attached for logging and errors.Is. Re-migrate the 12 admin
sites whose pre-1cc7eb6 source had a non-empty bespoke Message so
they restore the original wire string. Sites whose original Message
was empty stay on Internal(err) (humanization to "internal server
error" is a tolerable improvement, not a regression).

admin_smtp.go's send_failed site (line 129) was already preserved
via a raw &apierror.Error literal in 1cc7eb6 and needs no fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:18 -04:00

158 lines
4.7 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
// coverProviderResp is the wire shape for one provider in the
// GET /api/admin/cover-sources list. Mirrors coverart.ProviderInfo
// but uses snake_case JSON tags.
type coverProviderResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
RequiresAPIKey bool `json:"requires_api_key"`
Supports []string `json:"supports"`
Enabled bool `json:"enabled"`
APIKeySet bool `json:"api_key_set"`
DisplayOrder int32 `json:"display_order"`
Testable bool `json:"testable"`
}
type coverProvidersListResp struct {
Providers []coverProviderResp `json:"providers"`
SourcesVersion int32 `json:"sources_version"`
}
// handleListCoverSources implements GET /api/admin/cover-sources.
func (h *handlers) handleListCoverSources(w http.ResponseWriter, _ *http.Request) {
infos := h.coverSettings.ListProviderInfo()
out := coverProvidersListResp{
Providers: make([]coverProviderResp, 0, len(infos)),
SourcesVersion: h.coverSettings.CurrentVersion(),
}
for _, info := range infos {
supports := info.Supports
if supports == nil {
supports = []string{}
}
out.Providers = append(out.Providers, coverProviderResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
})
}
writeJSON(w, http.StatusOK, out)
}
// updateCoverSourceReq is the PATCH body. nil pointers mean
// "leave unchanged"; non-nil means "set to this value (which may be
// empty string for api_key clearing)".
type updateCoverSourceReq struct {
Enabled *bool `json:"enabled,omitempty"`
APIKey *string `json:"api_key,omitempty"`
}
type updateCoverSourceResp struct {
coverProviderResp
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateCoverSource implements PATCH /api/admin/cover-sources/{provider_id}.
func (h *handlers) handleUpdateCoverSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
var body updateCoverSourceReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
bumped, err := h.coverSettings.UpdateProvider(r.Context(), id, coverart.ProviderUpdatePatch{
Enabled: body.Enabled,
APIKey: body.APIKey,
})
if err != nil {
if errors.Is(err, coverart.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
h.logger.Error("admin: update cover source", "id", id, "err", err)
writeErr(w, apierror.InternalMsg("update failed", err))
return
}
// Re-fetch the provider info to return.
infos := h.coverSettings.ListProviderInfo()
var found coverProviderResp
for _, info := range infos {
if info.ID == id {
supports := info.Supports
if supports == nil {
supports = []string{}
}
found = coverProviderResp{
ID: info.ID,
DisplayName: info.DisplayName,
RequiresAPIKey: info.RequiresAPIKey,
Supports: supports,
Enabled: info.Enabled,
APIKeySet: info.APIKeySet,
DisplayOrder: info.DisplayOrder,
Testable: info.Testable,
}
break
}
}
writeJSON(w, http.StatusOK, updateCoverSourceResp{
coverProviderResp: found,
VersionBumped: bumped,
})
}
type testCoverSourceResp struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// handleTestCoverSource implements POST /api/admin/cover-sources/{provider_id}/test.
func (h *handlers) handleTestCoverSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
start := time.Now()
err := h.coverSettings.TestProvider(r.Context(), id)
duration := time.Since(start).Milliseconds()
if err == nil {
writeJSON(w, http.StatusOK, testCoverSourceResp{OK: true, DurationMs: duration})
return
}
if errors.Is(err, coverart.ErrProviderNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
return
}
// Not testable, or test failed — both surface as ok=false.
// 200 status because the operation completed; the test result is
// data, not an error.
writeJSON(w, http.StatusOK, testCoverSourceResp{
OK: false,
DurationMs: duration,
Error: err.Error(),
})
}