diff --git a/CLAUDE.md b/CLAUDE.md index 0534ad64..d81043fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | diff --git a/backend/app/downloads/credentials.py b/backend/app/downloads/credentials.py index 587af04f..39e1cd93 100644 --- a/backend/app/downloads/credentials.py +++ b/backend/app/downloads/credentials.py @@ -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.`, +# 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 diff --git a/backend/app/downloads/quota.py b/backend/app/downloads/quota.py index aca55ae0..e8d56f85 100644 --- a/backend/app/downloads/quota.py +++ b/backend/app/downloads/quota.py @@ -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.`, 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 diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index 13010aeb..ffc40cbf 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -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.`, 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) diff --git a/backend/app/reader/service.py b/backend/app/reader/service.py index 5402e653..5eb779c0 100644 --- a/backend/app/reader/service.py +++ b/backend/app/reader/service.py @@ -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.`, 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 diff --git a/backend/app/refusal.py b/backend/app/refusal.py index 95b2cf04..8e6b9ffd 100644 --- a/backend/app/refusal.py +++ b/backend/app/refusal.py @@ -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(…, "")` 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 diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index b872c0e7..b7e672d8 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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, diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index c4603e5b..29d7b082 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -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.`. 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": ""` 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 = { + 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.` // (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( diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 2549e42c..3e924f73 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -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": { diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 59f90d89..ff26192c 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -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": { diff --git a/frontend/src/i18n/unusedKeys.contract.test.ts b/frontend/src/i18n/unusedKeys.contract.test.ts index c7905892..70b29661 100644 --- a/frontend/src/i18n/unusedKeys.contract.test.ts +++ b/frontend/src/i18n/unusedKeys.contract.test.ts @@ -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, "*")); diff --git a/frontend/src/lib/api/refusalCodes.ts b/frontend/src/lib/api/refusalCodes.ts new file mode 100644 index 00000000..bf42be1d --- /dev/null +++ b/frontend/src/lib/api/refusalCodes.ts @@ -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: `.`. */ +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; + +/** 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; 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; + +/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */ +export const REFUSAL_UNSHOWN = { + subtitle: "answers a 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; diff --git a/frontend/src/lib/api/refusalText.dom.test.ts b/frontend/src/lib/api/refusalText.dom.test.ts new file mode 100644 index 00000000..138228ac --- /dev/null +++ b/frontend/src/lib/api/refusalText.dom.test.ts @@ -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")); + }); +}); diff --git a/frontend/src/lib/api/refusalText.ts b/frontend/src/lib/api/refusalText.ts index 1a90d127..abdcdb8a 100644 --- a/frontend/src/lib/api/refusalText.ts +++ b/frontend/src/lib/api/refusalText.ts @@ -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.`, 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(Object.entries(REFUSAL_FAMILIES)); +const byReason = new Map( + Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [ + code, + { reasons: new Map(Object.entries(f.reasons)), fallback: f.fallback }, + ]), +); +const fixed = new Map(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): Record { + 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"); }