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:
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "*"));
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user