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>
62 lines
2.1 KiB
TypeScript
62 lines
2.1 KiB
TypeScript
export type ApiError = {
|
|
code: string;
|
|
message: string;
|
|
status: number;
|
|
};
|
|
|
|
export type User = {
|
|
id: string;
|
|
username: string;
|
|
is_admin: boolean;
|
|
};
|
|
|
|
export type LoginResponse = {
|
|
token: string;
|
|
user: User;
|
|
};
|
|
|
|
export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
|
...init
|
|
});
|
|
const body = res.status === 204 ? null : await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
// Lazy import: auth/store imports from this file, so a top-level
|
|
// import would be circular. By the time any 401 actually happens
|
|
// at runtime, both modules have finished loading.
|
|
const { logout } = await import('$lib/auth/store.svelte');
|
|
await logout({ silent: true });
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
|
|
post: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
|
|
put: <T>(path: string, body: unknown): Promise<T> =>
|
|
apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise<T>,
|
|
del: (path: string): Promise<null> =>
|
|
apiFetch(path, { method: 'DELETE' }) as Promise<null>
|
|
};
|