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.
105 lines
6.5 KiB
TypeScript
105 lines
6.5 KiB
TypeScript
// 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>;
|