fix(i18n): every refusal the API answers is structured and said in the reader's language

~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
This commit is contained in:
2026-09-19 07:34:17 +02:00
parent dc4e8b9fcd
commit 7ae5c57b6b
39 changed files with 716 additions and 378 deletions
+15 -5
View File
@@ -50,7 +50,12 @@ export function isAborted(err: unknown): boolean {
export class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
detail?: string;
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`);
@@ -133,6 +138,11 @@ function localizeDetail(d: unknown): string {
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
@@ -326,8 +336,8 @@ export async function req<T = unknown>(
let detailData: unknown;
try {
const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
@@ -393,8 +403,8 @@ export function uploadReq<T = unknown>(
let detailData: unknown;
try {
const body = JSON.parse(xhr.responseText);
if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail);
}