feat(i18n): every server refusal is said by a record the contract holds

A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
This commit is contained in:
2026-10-30 23:36:25 +01:00
parent b3be568e36
commit 4379f76747
14 changed files with 381 additions and 123 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| User-facing error | structured `detail` (`{code, reason}`), its sentence named in `lib/api/refusalCodes.ts` (contract-held) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
+10
View File
@@ -25,6 +25,7 @@ Security controls enforced here:
import os
from pathlib import Path
from typing import Literal
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -163,9 +164,18 @@ def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredenti
).scalar_one_or_none()
# Every reason a pasted cookies.txt is refused; the UI names each one (`downloads.errors.cookies.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
CookieReason = Literal["empty", "format"]
class InvalidCookies(ValueError):
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
def __init__(self, reason: CookieReason):
super().__init__(reason)
self.reason = reason
def validate_cookies(raw: str) -> str:
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
+9 -3
View File
@@ -20,6 +20,7 @@ their output's real size before it is placed. Edit jobs do not consult it at all
"""
from dataclasses import dataclass
from typing import Literal
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -50,10 +51,15 @@ class QuotaLimits:
unlimited: bool
class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client)."""
# Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
QuotaReason = Literal["max_jobs", "max_bytes"]
def __init__(self, reason: str, limit: int, current: int):
class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
def __init__(self, reason: QuotaReason, limit: int, current: int):
self.reason = reason
self.limit = limit
self.current = current
+9 -3
View File
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
import uuid
from datetime import datetime, timezone
from typing import Literal
from sqlalchemy import delete as delete_rows
from sqlalchemy import func, select, update
@@ -39,10 +40,15 @@ from app.plex import paths as plex_paths
from app.userscope import owned_by, owned_row
class EditError(Exception):
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key."""
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
def __init__(self, reason: str):
class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
def __init__(self, reason: EditReason):
self.reason = reason
super().__init__(reason)
+7 -2
View File
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
from datetime import datetime, timezone
from typing import cast
from typing import Literal, cast
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -301,10 +301,15 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# --- playlists -------------------------------------------------------------------------------
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
class MoveRefused(Exception):
"""A move between playlists that cannot happen: `reason` is the code the UI translates."""
def __init__(self, reason: str):
def __init__(self, reason: MoveReason):
super().__init__(reason)
self.reason = reason
+4 -2
View File
@@ -8,8 +8,10 @@ whatever sentence the route happened to hand-write. `refused` is the generic fam
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
languages — so the rule is enforced, not described.
and the frontend's contract test (`i18n/backendIds.contract.test.ts`) requires every `code` a detail
carries to be in one record of `lib/api/refusalCodes.ts`, and every reason it can be sent with — a
literal, or a `Literal` type when it travels in a variable — to have its sentence in both languages.
So the rule is enforced, not described.
"""
from fastapi import HTTPException
+1 -1
View File
@@ -208,7 +208,7 @@ def set_credential(
try:
cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e:
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": e.reason}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record(
db,
+109 -7
View File
@@ -2,6 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState";
import {
REFUSAL_BY_REASON,
REFUSAL_FAMILIES,
REFUSAL_FIXED,
REFUSAL_UNSHOWN,
} from "../lib/api/refusalCodes";
import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
@@ -87,13 +93,9 @@ const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
// ruff wraps over several lines still matches.
const adminCodes = [
...(
readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const adminCodes = Object.entries(REFUSAL_FAMILIES)
.filter(([, f]) => f.prefix === "errors.admin")
.map(([code]) => code);
const adminRefusals = [
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
].filter((m) => adminCodes.includes(m[1]!));
@@ -214,6 +216,54 @@ const clipReasons = [
),
];
// Every refusal code the backend answers: each `"code": "<x>"` of a detail anywhere in the app. A
// subtitle's status payload (`{"state": …, "code": …}`) is the player's own vocabulary, not a refusal.
const backendCodes = [
...new Set(
appSources().flatMap((src) =>
src
.split("\n")
.filter((line) => !line.includes('"state":'))
.flatMap((line) => [...line.matchAll(/"code":\s*"(\w+)"/g)].map((m) => m[1]!)),
),
),
];
const literalOf = (file: string, name: string) =>
[
...(
backend(file).match(new RegExp(`^${name} = Literal\\[([^\\]]*)\\]`, "m"))?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const literalCalls = (file: string, re: RegExp) => [
...new Set([...backend(file).matchAll(re)].map((m) => m[1]!)),
];
const detailReasons = (file: string, code: string) =>
literalCalls(file, new RegExp(`"code":\\s*"${code}",\\s*"reason":\\s*"(\\w+)"`, "g"));
// What each refusal code can carry as its reason, read where it is decided: a Literal when the reason
// travels in a variable (`e.reason`), the call's own literal otherwise.
const reasonsOf: Record<string, string[]> = {
refused: refusedReasons,
youtube: youtubeReasons,
not_found: goneWhats,
clip: clipReasons,
...Object.fromEntries(adminCodes.map((c) => [c, detailReasons("routes/admin.py", c)])),
playlist: literalCalls("routes/playlists.py", /_refused\(\s*\d+,\s*"(\w+)"/g),
playlist_move: literalOf("reader/service.py", "MoveReason"),
edit: literalOf("downloads/service.py", "EditReason"),
cookies: literalOf("downloads/credentials.py", "CookieReason"),
url_blocked: literalCalls("downloads/urlguard.py", /^REASON_\w+ = "(\w+)"/gm),
login: literalCalls("routes/auth.py", /_login_refused\(\s*\d+,\s*"(\w+)"/g),
quota: literalOf("downloads/quota.py", "QuotaReason"),
reset: detailReasons("routes/auth.py", "reset"),
collection: detailReasons("routes/plex/collections.py", "collection"),
};
const recordedCodes = [
...Object.keys(REFUSAL_FAMILIES),
...Object.keys(REFUSAL_BY_REASON),
...Object.keys(REFUSAL_FIXED),
...Object.keys(REFUSAL_UNSHOWN),
];
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
@@ -301,6 +351,25 @@ describe("backend ids have their names in both languages", () => {
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
});
it("every refusal code the backend answers is said by exactly one record, and no record names another", () => {
expect(backendCodes).toEqual(expect.arrayContaining(["refused", "playlist", "too_large"]));
expect(backendCodes.filter((c) => recordedCodes.filter((r) => r === c).length !== 1)).toEqual(
[],
);
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
});
it("read every refusal's reasons (a scan that matches nothing is green over nothing)", () => {
const coded = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
expect(Object.keys(reasonsOf).sort()).toEqual(coded.sort());
expect(Object.entries(reasonsOf).filter(([, rs]) => rs.length === 0)).toEqual([]);
});
it("a code whose reasons share sentences names every reason it is sent with, and no other", () => {
for (const [code, { reasons }] of Object.entries(REFUSAL_BY_REASON))
expect(Object.keys(reasons).sort(), code).toEqual([...reasonsOf[code]!].sort());
});
it("the run list's status chips are the server's run statuses", () => {
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
});
@@ -456,6 +525,39 @@ describe("backend ids have their names in both languages", () => {
}
});
it(`${lang}: every refusal the backend can answer has its sentence`, () => {
const wanted = [
...Object.entries(REFUSAL_FAMILIES).flatMap(([code, f]) => [
...reasonsOf[code]!.map((r) => `${f.prefix}.${r}${"suffix" in f ? f.suffix : ""}`),
f.fallback,
]),
...Object.values(REFUSAL_BY_REASON).flatMap((f) => [
...Object.values(f.reasons),
f.fallback,
]),
...Object.values(REFUSAL_FIXED),
"errors.generic",
];
expect(missing([...new Set(wanted)])).toEqual([]);
});
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
// A family whose prefix is its own (the shared `errors.admin`, and the sign-in screens, which
// name more than the one-line refusal, are checked elsewhere or not at all).
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
if (f.prefix === "errors.admin" || "suffix" in f) continue;
const fallbackId = f.fallback.startsWith(`${f.prefix}.`)
? f.fallback.slice(f.prefix.length + 1)
: null;
expect(
[...idsUnder(keys, f.prefix)].filter(
(r) => r !== fallbackId && !reasonsOf[code]!.includes(r),
),
code,
).toEqual([]);
}
});
it(`${lang}: every admin refusal reason`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
@@ -219,6 +219,7 @@
"edit": {
"empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit",
"not_media": "Only video and audio can be edited",
"generic": "Couldn't create that edit"
},
"upload": {
@@ -219,6 +219,7 @@
"edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
"not_media": "Csak videó és hang szerkeszthető",
"generic": "Nem sikerült létrehozni a vágást"
},
"upload": {
@@ -3,6 +3,8 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { REFUSAL_FAMILIES, type RefusalFamily } from "../lib/api/refusalCodes";
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step
@@ -49,9 +51,14 @@ const code = sources(SRC).join("\n");
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
// A refusal's sentence is built from its record (`lib/api/refusalCodes`) rather than a template the
// scan could read, so each family's prefix is a shape of its own.
const shapes = [
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
...Object.values(REFUSAL_FAMILIES).map(
(f: RefusalFamily) => `${f.prefix}.\${reason}${f.suffix ?? ""}`,
),
]
.filter((s) => s.includes("${"))
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
+104
View File
@@ -0,0 +1,104 @@
// Which sentence answers each server refusal (`{ code, reason, … }`), as data: `localizeDetail` says a
// refusal through these records, and `i18n/backendIds.contract.test.ts` reads every code and reason the
// backend can send and requires each to land here with its sentence in both languages. A refusal that
// fell through to the generic "couldn't carry out that action" used to stay unnoticed — the language
// parity test is green when EN and HU miss the same key.
//
// No imports: the contract test reads this module in a plain node run, without i18n.
/** A code whose reasons each name their own sentence: `<prefix>.<reason><suffix>`. */
export type RefusalFamily = { prefix: string; suffix?: string; fallback: string };
export const REFUSAL_FAMILIES = {
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and the
// rest of the detail its values.
refused: { prefix: "errors.refused", fallback: "errors.generic" },
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it — wait
// for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
youtube: { prefix: "errors.youtube", fallback: "errors.youtube.unavailable" },
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's row
// 404s the same way); `reason` names WHAT is gone.
not_found: { prefix: "errors.notFound", fallback: "errors.notFound.generic" },
// A clip the server will not cut (downloads/clip): the range, the frame, the source; a cap comes
// with the refusal (`limit_s`).
clip: { prefix: "clip.errors", fallback: "clip.failed" },
// An admin action on an account or an invite: an address no longer approved, a link or invitation
// with nothing to go to or no mail to go by. The toast's title names the button; this is the WHY,
// which used to be the generic "try again" no retry could fix. One namespace for the three codes.
activate: { prefix: "errors.admin", fallback: "errors.generic" },
resend: { prefix: "errors.admin", fallback: "errors.generic" },
invite_send: { prefix: "errors.admin", fallback: "errors.generic" },
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
playlist: { prefix: "playlists.errors", fallback: "errors.generic" },
// The one-step "move to another list": both reasons mean the list moved under you, named apart.
playlist_move: { prefix: "playlists.moveErrors", fallback: "playlists.moveFailed" },
edit: { prefix: "downloads.errors.edit", fallback: "downloads.errors.edit.generic" },
cookies: { prefix: "downloads.errors.cookies", fallback: "downloads.errors.cookies.generic" },
// A download URL the server refuses to fetch. The reasons are deliberately distinct — "we could not
// look that host up" is a different thing to tell someone than "we will not fetch from that address".
url_blocked: {
prefix: "downloads.errors.urlBlocked",
fallback: "downloads.errors.urlBlocked.generic",
},
// Password sign-in refusals, in one line. The sign-in card renders the account-shaped ones as a full
// panel off `detailData`; this is the form every other consumer (and the card's fallback) gets.
login: {
prefix: "welcome.auth.blocked",
suffix: ".short",
fallback: "welcome.auth.genericError",
},
} satisfies Record<string, RefusalFamily>;
/** A code whose few reasons share sentences: each reason the backend sends is named here. */
export const REFUSAL_BY_REASON = {
quota: {
reasons: {
max_bytes: "downloads.errors.quota.max_bytes",
max_jobs: "downloads.errors.quota.max_jobs",
},
fallback: "downloads.errors.quota.generic",
},
// The set-password link. Its failure is one a user meets by simply clicking an old link, so it may
// not answer in hardcoded English under a bilingual UI.
reset: {
reasons: { weak_password: "welcome.auth.weakPassword", invalid: "welcome.auth.deadLink.short" },
fallback: "welcome.auth.deadLink.short",
},
// A Plex collection name already taken in that library (the one collection-edit refusal).
collection: {
reasons: { name_taken: "plex.collection.nameTaken" },
fallback: "plex.collection.editFailed",
},
} satisfies Record<string, { reasons: Record<string, string>; fallback: string }>;
/** A code with one sentence, whatever else it carries. */
export const REFUSAL_FIXED = {
// The email confirmation link — like `reset`, met by clicking an old link.
verify: "welcome.auth.verifyInvalid",
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
upload_too_large: "downloads.errors.upload.tooLarge",
upload_empty: "downloads.errors.upload.empty",
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. On a DEFAULT install it is what an over-cap upload gets, because the ceiling and the
// route's own cap are the same figure and the edge answers first — the numbered `upload_too_large`
// answers only once an admin has LOWERED the knob (bodylimit.ROUTE_MAX_BODY_BYTES).
// A BACKSTOP, not the plan: the middleware answers without draining, so whether the browser reads
// this at all depends on the client having finished sending (readable up to 200 MiB over loopback,
// a bare `onerror` through a dev proxy). That is why the upload dialog checks the file against
// `upload_max_bytes` BEFORE sending. The sentence stays PAYLOAD-NEUTRAL: a JSON form gets it too
// (`debug_sessions` refuses an over-size draft with the same code), and advice about a file told the
// issue composer's user to pick a smaller one — of nothing.
too_large: "errors.tooLarge",
// A manual sync trigger on cooldown: a pace limit rather than an error, and the copy says so.
sync_cooldown: "errors.syncCooldown",
} satisfies Record<string, string>;
/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */
export const REFUSAL_UNSHOWN = {
subtitle: "answers a <track> src; the player polls the track's status and never reads this body",
json_required: "answers the client log beacon, which the UI always sends as JSON",
diagnostics_off: "answers the client log beacon; the client stops streaming on it, silently",
rate_limited: "answers the client log beacon over its rate; dropped lines are not reported",
bad_cursor: "a list cursor the UI itself wrote; only a hand-edited request gets this",
invalid_input: "a NUL byte in a request, which no form of the UI can send",
} satisfies Record<string, string>;
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
//
// A server refusal said through the records (`refusalCodes`), in both languages, by the real i18n —
// one case per record shape, so moving the dispatch into data changed what no reader sees.
import { afterEach, describe, expect, it } from "vitest";
import i18n from "../../i18n";
import { localizeDetail } from "./refusalText";
afterEach(async () => {
await i18n.changeLanguage("en");
});
describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
const say = async (d: unknown) => {
await i18n.changeLanguage(lng);
return localizeDetail(d);
};
it("names an edit refused for a file that is not video or audio", async () => {
const said = await say({ code: "edit", reason: "not_media" });
expect(said).toBe(i18n.t("downloads.errors.edit.not_media"));
expect(said).not.toBe(i18n.t("downloads.errors.edit.generic"));
});
it("says a family's reason, and its own fallback for a reason it does not know", async () => {
expect(await say({ code: "playlist_move", reason: "same_playlist" })).toBe(
i18n.t("playlists.moveErrors.same_playlist"),
);
expect(await say({ code: "playlist_move", reason: "nope" })).toBe(
i18n.t("playlists.moveFailed"),
);
});
it("reads a sign-in refusal's one-line form", async () => {
expect(await say({ code: "login", reason: "suspended" })).toBe(
i18n.t("welcome.auth.blocked.suspended.short"),
);
});
it("puts a refusal's numbers into its sentence", async () => {
expect(await say({ code: "quota", reason: "max_jobs", limit: 7 })).toContain("7");
expect(await say({ code: "reset", reason: "weak_password", min: 12 })).toContain("12");
expect(await say({ code: "refused", reason: "view_name_taken", name: "Esti" })).toContain(
"Esti",
);
});
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
expect(await say({ code: "reset", reason: "invalid" })).toBe(
i18n.t("welcome.auth.deadLink.short"),
);
expect(await say({ code: "sync_cooldown" })).toBe(i18n.t("errors.syncCooldown"));
});
it("reads a code no screen shows, or one it does not know, as the generic failure", async () => {
expect(await say({ code: "bad_cursor" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "never_sent" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "constructor" })).toBe(i18n.t("errors.generic"));
expect(await say(null)).toBe(i18n.t("errors.generic"));
});
});
+57 -104
View File
@@ -3,15 +3,52 @@
// moment, and the notice store cannot import the request core that imports it.
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import {
REFUSAL_BY_REASON,
REFUSAL_FAMILIES,
REFUSAL_FIXED,
type RefusalFamily,
} from "./refusalCodes";
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
type Detail = {
code?: string;
reason?: string;
limit?: number;
limit_s?: number;
min?: number;
} | null;
// Which sentence answers which code lives in `refusalCodes` — the records the backendIds contract holds
// against every code and reason the backend can send. A Map, so a code is looked up as data only.
const families = new Map<string, RefusalFamily>(Object.entries(REFUSAL_FAMILIES));
const byReason = new Map(
Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [
code,
{ reasons: new Map<string, string>(Object.entries(f.reasons)), fallback: f.fallback },
]),
);
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
const lang = i18n.language;
if (o.code === "refused") return { ...o };
if (o.code === "clip")
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
if (o.code === "quota")
return o.reason === "max_bytes"
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
: { limit: o.limit };
if (o.code === "reset") return { min: o.limit ?? o.min };
if (o.code === "upload_too_large") return { size: formatBytes(o.limit, lang) };
return {};
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. A reason this
// build does not know reads as its code's fallback sentence rather than a raw key, and a code it does
// not know (or one no screen shows, `REFUSAL_UNSHOWN`) as the generic failure.
export function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
@@ -19,102 +56,18 @@ export function localizeDetail(d: unknown): string {
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
const o = d as Detail | undefined;
if (!o?.code) return t("errors.generic");
const family = families.get(o.code);
if (family)
return t(`${family.prefix}.${o.reason}${family.suffix ?? ""}`, {
...valuesOf(o),
defaultValue: t(family.fallback),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// 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
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
const shared = byReason.get(o.code);
if (shared) {
return t(shared.reasons.get(o.reason ?? "") ?? shared.fallback, valuesOf(o));
}
const one = fixed.get(o.code);
return one ? t(one, valuesOf(o)) : t("errors.generic");
}