// 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;