// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request // core's: a notice keeps the refusal it reports and says it again at display, in the language of the // moment, and the notice store cannot import the request core that imports it. import i18n from "../../i18n"; import { formatBytes, formatDecimal } from "../format"; // 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"]); 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 // well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and // `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), }); 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"); }