fix(api): give an expired subtitle candidate a typed error

A grab naming a stale candidate_id now fails as ApiError::SubtitleCandidateExpired (404, code candidate_expired) instead of the generic upstream 503 string the panel had to pattern-match for 'not found'.
This commit is contained in:
Miguel Palhas
2026-08-25 05:33:43 +01:00
parent e0e7ddf6de
commit 2357e72113
5 changed files with 100 additions and 23 deletions
+4 -3
View File
@@ -1320,9 +1320,10 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS
say(note, "fetching, syncing, writing…");
const outcome = await grabSubtitle(mediaFileId, candidate);
if (outcome.kind === "error") {
const stale = /not found/i.test(outcome.detail)
? " — search again, a candidate id does not outlive its search"
: "";
const stale =
outcome.code === "candidate_expired"
? " — search again, a candidate id does not outlive its search"
: "";
say(note, `grab failed — ${outcome.detail}${stale}`, "fault");
return false;
}
+15
View File
@@ -463,3 +463,18 @@ export async function errorDetail(response: Response): Promise<string> {
return `http ${response.status}`;
}
}
/**
* Like {@link errorDetail}, but also surfaces `ErrorBody.code` — the
* machine-readable discriminant a client switches on instead of matching the
* message text (issue #221). `code` is `undefined` on every error that has
* none, which is most of them.
*/
export async function errorBody(response: Response): Promise<{ detail: string; code?: string }> {
try {
const body = (await response.json()) as { error?: string; code?: string };
return { detail: body.error ?? `http ${response.status}`, code: body.code };
} catch {
return { detail: `http ${response.status}` };
}
}
+8 -3
View File
@@ -2,7 +2,7 @@
// schemas — same reasoning as search.ts: the generated client (src/api/) is
// uncommitted, so CI's tsc cannot see it.
import { errorDetail } from "./releases";
import { errorBody, errorDetail } from "./releases";
/** One subtitle arr knows about, as `/subtitles` and `/subtitles/status` render it. */
export interface Subtitle {
@@ -201,12 +201,16 @@ export async function searchSubtitles(
export type SubtitleWriteOutcome =
| { kind: "done"; subtitle: Subtitle }
| { kind: "error"; detail: string };
| { kind: "error"; detail: string; code?: string };
/**
* One click: the daemon fetches the candidate, runs `alass` over it and
* writes the sidecar (§15). The candidate's own flags travel with it —
* nothing on the server remembers a search.
*
* A grab can fail because the candidate itself expired between the search
* and the click (issue #221) — the server reports that as `code:
* "candidate_expired"`, not by wording the message a particular way.
*/
export async function grabSubtitle(
mediaFileId: number,
@@ -225,7 +229,8 @@ export async function grabSubtitle(
}),
});
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
const { detail, code } = await errorBody(response);
return { kind: "error", detail, code };
}
return { kind: "done", subtitle: (await response.json()) as Subtitle };
} catch {