fix(admin): approve flow surfaces real errors; auto-default Lidarr picks

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>
This commit is contained in:
2026-05-01 23:37:17 -04:00
parent 8c62f0089e
commit ab8235dd0b
7 changed files with 86 additions and 11 deletions
+15 -6
View File
@@ -30,12 +30,21 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
const { logout } = await import('$lib/auth/store.svelte');
await logout({ silent: true });
}
const envelope = body && (body as { error?: { code?: string; message?: string } }).error;
const err: ApiError = {
code: envelope?.code ?? 'unknown',
message: envelope?.message ?? res.statusText,
status: res.status
};
// Two error envelope shapes ship in this codebase today:
// 1) {"error": {"code": "...", "message": "..."}} — non-admin endpoints
// 2) {"error": "code_string"} — admin endpoints
// Without handling (2), every admin error toast renders 'unknown'.
const errorField = body && (body as { error?: unknown }).error;
let code = 'unknown';
let message = res.statusText;
if (typeof errorField === 'string') {
code = errorField;
} else if (errorField && typeof errorField === 'object') {
const env = errorField as { code?: string; message?: string };
code = env.code ?? 'unknown';
message = env.message ?? res.statusText;
}
const err: ApiError = { code, message, status: res.status };
throw err;
}
return body;
@@ -39,6 +39,21 @@
}
});
// Auto-default to the first option Lidarr returns when the operator
// hasn't picked one yet. Saves a click for the common case (one
// quality profile + one root folder, which is what most home setups
// have). The operator can still change either before saving.
$effect(() => {
if (qualityId === 0 && profiles.data && profiles.data.length > 0) {
qualityId = profiles.data[0].id;
}
});
$effect(() => {
if (rootPath === '' && folders.data && folders.data.length > 0) {
rootPath = folders.data[0].path;
}
});
// The dropdown queries only fire once Lidarr is configured; otherwise the
// backend has no client to call and would 4xx.
const profilesEnabled = $derived(!!config.data?.enabled);
+9 -1
View File
@@ -70,13 +70,21 @@
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable':
return "Lidarr is unreachable right now. Try again, or check Settings → Integrations.";
return "Lidarr is unreachable right now. Try again, or check Admin → Integrations.";
case 'lidarr_disabled':
return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed':
return 'Lidarr authentication failed.';
case 'lidarr_defaults_incomplete':
return 'Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.';
case 'lidarr_server_error':
return "Lidarr returned an error. Check Lidarr's logs for the cause.";
case 'lidarr_rejected':
return 'Lidarr rejected the request — usually means the artist or album is already in your library.';
case 'request_not_pending':
return 'This request is no longer pending.';
case 'request_not_found':
return 'That request no longer exists.';
default:
return code ? code : "Couldn't reach Lidarr.";
}