ab8235dd0b
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:
1. Lidarr config let enabled=true save with default_quality_profile_id=0
and default_root_folder_path=''. Approve then sent invalid POST bodies
to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
endpoints write {error: 'code_string'}. Every admin error toast
rendered as 'unknown'.
Fixes:
- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
body when it returns 4xx/5xx; include in the wrapped error so server
logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
before the Lidarr call when QP=0 or root_folder=''. Stops the bad
POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
ErrServerError -> 'lidarr_server_error' (502),
ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
first quality profile and first root folder Lidarr returns when the
operator hasn't picked one yet — saves a click for typical
one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
the new error codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
266 lines
9.2 KiB
Go
266 lines
9.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
)
|
|
|
|
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
|
|
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
|
|
type lidarrConfigView struct {
|
|
Enabled bool `json:"enabled"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
DefaultQualityProfileID int `json:"default_quality_profile_id"`
|
|
DefaultRootFolderPath string `json:"default_root_folder_path"`
|
|
}
|
|
|
|
// maskAPIKey converts an api_key for external response: non-empty keys become
|
|
// "***"; empty (never set) keys remain "".
|
|
func maskAPIKey(key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
return "***"
|
|
}
|
|
|
|
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
|
|
// api_key masked.
|
|
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
|
|
return lidarrConfigView{
|
|
Enabled: cfg.Enabled,
|
|
BaseURL: cfg.BaseURL,
|
|
APIKey: maskAPIKey(cfg.APIKey),
|
|
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
|
|
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
|
|
}
|
|
}
|
|
|
|
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
|
|
// Returns the current Lidarr config with api_key masked.
|
|
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := h.lidarrCfg.Get(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: get lidarr config", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, configToView(cfg))
|
|
}
|
|
|
|
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
|
|
type putLidarrConfigBody struct {
|
|
Enabled bool `json:"enabled"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
DefaultQualityProfileID int `json:"default_quality_profile_id"`
|
|
DefaultRootFolderPath string `json:"default_root_folder_path"`
|
|
}
|
|
|
|
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
|
|
// Empty api_key in the body preserves the currently saved api_key.
|
|
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
|
|
var body putLidarrConfigBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
|
|
return
|
|
}
|
|
|
|
// Resolve the effective api_key: empty in body = preserve saved value.
|
|
apiKey := body.APIKey
|
|
if apiKey == "" {
|
|
saved, err := h.lidarrCfg.Get(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: put lidarr config: load saved", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
return
|
|
}
|
|
apiKey = saved.APIKey
|
|
}
|
|
|
|
// Validate: enabled=true requires base_url AND api_key.
|
|
if body.Enabled {
|
|
if body.BaseURL == "" || apiKey == "" {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
|
|
return
|
|
}
|
|
// Validate that the URL parses.
|
|
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
|
|
return
|
|
}
|
|
// Defaults must be present so future Approve calls don't dispatch
|
|
// invalid POST bodies to Lidarr (which produces 5xx with no useful
|
|
// detail). The frontend pre-selects first values so this almost
|
|
// never fires for non-malicious traffic, but it's the right gate.
|
|
if body.DefaultQualityProfileID == 0 || body.DefaultRootFolderPath == "" {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
|
|
return
|
|
}
|
|
}
|
|
|
|
cfg := lidarrconfig.Config{
|
|
Enabled: body.Enabled,
|
|
BaseURL: body.BaseURL,
|
|
APIKey: apiKey,
|
|
DefaultQualityProfileID: body.DefaultQualityProfileID,
|
|
DefaultRootFolderPath: body.DefaultRootFolderPath,
|
|
}
|
|
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
|
|
h.logger.Error("admin: put lidarr config: save", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, configToView(cfg))
|
|
}
|
|
|
|
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
|
|
type testLidarrBody struct {
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
|
|
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
|
|
// Always returns 200; the ok/error fields in the response body indicate
|
|
// connection success or failure.
|
|
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
|
|
var body testLidarrBody
|
|
// Ignore decode errors — an empty body is valid (all fields optional).
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
// Fall back to saved config for any empty field.
|
|
baseURL := body.BaseURL
|
|
apiKey := body.APIKey
|
|
if baseURL == "" || apiKey == "" {
|
|
saved, err := h.lidarrCfg.Get(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: test lidarr: load config", "err", err)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
|
|
return
|
|
}
|
|
if baseURL == "" {
|
|
baseURL = saved.BaseURL
|
|
}
|
|
if apiKey == "" {
|
|
apiKey = saved.APIKey
|
|
}
|
|
}
|
|
|
|
client := lidarr.NewClient(baseURL, apiKey)
|
|
result, err := client.Ping(r.Context())
|
|
if err != nil {
|
|
errCode := lidarrErrCode(err)
|
|
// Warn (not Error): a failed connection test is expected when the
|
|
// admin is configuring the integration and may have a typo'd URL
|
|
// or wrong key. Without this log line the underlying *net.DNSError
|
|
// / *net.OpError / TLS error is invisible to operators — they only
|
|
// see the bucket code in the UI. base_url is safe to log; api_key
|
|
// must NEVER be logged.
|
|
h.logger.Warn("admin: test lidarr ping failed",
|
|
"err", err, "base_url", baseURL, "code", errCode)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": errCode})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": result.Version})
|
|
}
|
|
|
|
// lidarrErrCode maps a lidarr client error to the stable string code used in
|
|
// admin API responses.
|
|
func lidarrErrCode(err error) string {
|
|
switch {
|
|
case errors.Is(err, lidarr.ErrUnreachable):
|
|
return "lidarr_unreachable"
|
|
case errors.Is(err, lidarr.ErrAuthFailed):
|
|
return "lidarr_auth_failed"
|
|
case errors.Is(err, lidarr.ErrLookupFailed):
|
|
return "lidarr_lookup_failed"
|
|
default:
|
|
return "internal_error"
|
|
}
|
|
}
|
|
|
|
// qualityProfileView is the JSON shape for a single quality profile in the
|
|
// GET /api/admin/lidarr/quality-profiles response.
|
|
type qualityProfileView struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
|
|
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
|
|
_, client, ok := h.lidarrClientFromConfig(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
profiles, err := client.ListQualityProfiles(r.Context())
|
|
if err != nil {
|
|
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
|
|
return
|
|
}
|
|
out := make([]qualityProfileView, len(profiles))
|
|
for i, p := range profiles {
|
|
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// rootFolderView is the JSON shape for a single root folder in the
|
|
// GET /api/admin/lidarr/root-folders response.
|
|
type rootFolderView struct {
|
|
Path string `json:"path"`
|
|
Accessible bool `json:"accessible"`
|
|
FreeSpace int64 `json:"free_space"`
|
|
}
|
|
|
|
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
|
|
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
|
|
_, client, ok := h.lidarrClientFromConfig(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
folders, err := client.ListRootFolders(r.Context())
|
|
if err != nil {
|
|
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
|
|
return
|
|
}
|
|
out := make([]rootFolderView, len(folders))
|
|
for i, f := range folders {
|
|
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
|
|
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
|
|
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
|
|
// when a response has already been written.
|
|
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
|
|
cfg, err := h.lidarrCfg.Get(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("admin: lidarr proxy: load config", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
|
|
return lidarrconfig.Config{}, nil, false
|
|
}
|
|
if !cfg.Enabled {
|
|
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
|
return lidarrconfig.Config{}, nil, false
|
|
}
|
|
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
|
|
}
|
|
|
|
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
|
|
// mirrors the shape the RequireAdmin middleware uses and that the spec
|
|
// defines for /api/admin/* errors.
|
|
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
|
|
}
|