// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed // error, structured-error localization, the connectivity toast, the per-tab active-account plumbing, // and the keepalive beacon. Domain modules import from here; nothing here imports a domain module, // so the runtime graph is a clean barrel → domain → core with no cycles. import { getActiveToasts, notify, remove } from "../notifications"; import i18n from "../../i18n"; import { formatBytes } from "../format"; import { reportError } from "../errorDialog"; import { activeAccountId, ACTIVE_ACCOUNT_KEY, pinActiveAccount } from "../activeAccount"; /** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB"). * Lives here so the request builders can stamp it themselves: a signal that has to be passed in by * every caller is one every caller can forget, which is how the registration language shipped as a * field nobody ever filled (E9.5 S6 → S7). */ export const uiLang = (): string => i18n.language.split("-")[0] || "en"; // Errors `req` has already put in front of the user for good — the blocking error dialog, or the // sign-out a 401 triggers. A caller that toasts a failed action as well reported the same fault twice // (a modal AND a toast); `notifyActionError` asks this instead of each caller filtering statuses by // hand. // // NOT the connection notice. It says the server is unreachable, not that this action failed, and it // removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked // as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was // then reported nowhere at all, the list still there (deep-review round 4). // // A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets // the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError` // already said something, and speak for every one that did not. const reported = new WeakSet(); export function alreadyReported(err: unknown): boolean { return typeof err === "object" && err !== null && reported.has(err); } /** Record that this failure is on screen now, so nothing reports it a second time. */ export function markReported(err: unknown): void { if (typeof err === "object" && err !== null) reported.add(err); } /** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the * only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so * the difference has to be a named thing both sides agree on, not a string literal in each. */ const ABORTED = "aborted"; /** True for the rejection of a request the user cancelled (see `ABORTED`). */ export function isAborted(err: unknown): boolean { return err instanceof HttpError && err.status === 0 && err.detail === ABORTED; } export class HttpError extends Error { status: number; detail?: string; // server-provided reason (FastAPI's `detail`), when present detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object constructor(status: number, detail?: string, detailData?: unknown) { super(detail || `HTTP ${status}`); this.status = status; this.detail = detail; this.detailData = detailData; } } // 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", "setpw"]); 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; min?: number } | null | undefined; if (o?.code === "quota") { if (o.reason === "max_bytes") { const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( (o.limit || 0) / 1_073_741_824, ); 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")); if (o?.code === "cookies") return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic")); // Password sign-in refusals (E9.5 S4). 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 shared demo account refusing a personal-state write (`require_human`). Not every affordance // behind that gate is UI-gated on `is_demo` — the comfort shelf is not — so without this the demo // user got their caller's generic "couldn't do that" and no reason at all (round 6). if (o?.code === "demo") return t("errors.demo"); // 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 (E7 S5): 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) }); if (o?.code === "upload_empty") return t("downloads.errors.upload.empty"); // 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")); // E10 S2: 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"); // 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, a set-password link for the shared demo account. 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"); } // A single, self-resolving "connection lost" status: while the server is unreachable we // show one sticky, transient (non-persisted) notice; the moment any request reaches the // server again we remove it. This makes good on its "clears once it's back" copy and keeps // bursts of concurrent failures from stacking up — there's only ever one live handle. let connectivityNotifId: number | null = null; function markConnectivityLost(): void { // One live notice — but a notice the user closed is not live: it used to swallow every later // "connection lost" for the rest of the visit. const id = connectivityNotifId; if (id !== null && getActiveToasts().some((n) => n.id === id)) return; if (id !== null) remove(id); connectivityNotifId = notify({ level: "error", title: i18n.t("errors.offline.title"), message: i18n.t("errors.offline.body"), transient: true, }); } function markConnectivityRestored(): void { if (connectivityNotifId === null) return; remove(connectivityNotifId); connectivityNotifId = null; } // Gateway statuses that mean "the upstream connection failed", not "the app rejected the // request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the // request was processed. Safe to retry an idempotent call once on a fresh connection. const RETRIABLE_GATEWAY = new Set([502, 503, 504]); const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms)); // What a failed answer puts on screen — ONE policy for `req` and `uploadReq` (the upload path kept // its own copy, and it had drifted: a 502 there opened the blocking "Server error" dialog and // cleared a connection notice that was still true). // // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), not a // request the app refused — the soft, self-clearing connection notice rather than a blocking modal, // and still the caller's to report as a failed action (see `reported`). A genuine 500 (real fault, // carries a JSON detail) gets the modal, as do 400/409/422 (validation/conflict). 401 is the app // shell's; 403/404/429 are caller-handled control flow. function reportFailure(err: HttpError, quiet: boolean): void { if (RETRIABLE_GATEWAY.has(err.status)) { markConnectivityLost(); } else if (err.status === 401) { // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the app // shell decide what to do (drop to the login page if we were signed in). Not a modal — but // handled all the same, so not the caller's to toast either. onUnauthorized?.(); reported.add(err); } else if (quiet) { /* caller handles the error inline — no global modal */ } else if (err.status >= 500) { reportError(err.detail || `${i18n.t("errors.server")} (${err.status})`); reported.add(err); } else if (err.status === 400 || err.status === 409 || err.status === 422) { reportError(err.detail); reported.add(err); } } // `idempotent`: may this request be replayed after a transient gateway/network failure? // GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which // are safe to repeat). Used to recover from the keepalive race without surfacing a 502. export interface ReqConfig { idempotent?: boolean; // Suppress the global error modal for 4xx/5xx so the caller can show the error inline // (used by the auth forms — a wrong password shouldn't pop a blocking dialog). quiet?: boolean; } // Set by the app shell. Invoked whenever any request returns 401 so a session that ended // server-side (account suspended/deleted while the tab was open) can drop the user to the login // page. The handler itself guards against firing when we were never signed in, so it's safe to // call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null // user when logged out — but other protected endpoints still do.) let onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(fn: (() => void) | null): void { onUnauthorized = fn; } // Headers for install-wizard calls: the one-time setup token plus the JSON content type (req // replaces — not merges — its default headers when opts.headers is given, so include both). export const setupHeaders = (token: string) => ({ "Content-Type": "application/json", "X-Setup-Token": token, }); // --- Per-tab active account -------------------------------------------------------------- // The signed session cookie is the browser's account "wallet"; which account a given TAB acts // as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in // sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. // The storage key + reader live in storage.ts (so its per-account helpers can use them too). const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; export const getActiveAccount = activeAccountId; // Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position // beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup // doesn't run on a full reload, so without keepalive the last position (esp. right after a seek) // would be lost. export function beacon(url: string, body: Record): void { const active = getActiveAccount(); try { void fetch(url, { method: "POST", keepalive: true, credentials: "include", headers: { "Content-Type": "application/json", ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), }, body: JSON.stringify(body), }).catch(() => {}); } catch { /* ignore */ } } export const setActiveAccount = pinActiveAccount; export function clearActiveAccount(): void { try { sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); } catch { /* ignore */ } } export async function req( url: string, opts: RequestInit = {}, cfg: ReqConfig = {}, ): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; let attempt = 0; for (;;) { let r: Response; try { const active = getActiveAccount(); r = await fetch(url, { credentials: "include", headers: { "Content-Type": "application/json", // Per-tab identity override (only setup calls pass their own headers, and those are // pre-auth, so this is safe to put in the defaults that `...opts` may replace). ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), }, ...opts, }); } catch (e) { // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError). if (canRetry && attempt === 0) { attempt++; await delay(250); continue; } markConnectivityLost(); // the connection notice; the failed action is still the caller's throw e; } // A gateway error usually means the upstream connection was reset before our request // was processed — retry idempotent calls once on a fresh connection before surfacing it. if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) { attempt++; await delay(250); continue; } // The server answered (anything that isn't a gateway error means it's reachable) — // clear a standing "connection lost" status if one is showing. if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored(); if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; let detailData: unknown; try { const body = await r.json(); if (body && typeof body.detail === "string") detail = body.detail; else if (body && body.detail && typeof body.detail === "object") { detailData = body.detail; detail = localizeDetail(body.detail); // structured error → localized message } } catch { /* no JSON body */ } const err = new HttpError(r.status, detail, detailData); reportFailure(err, !!cfg.quiet); throw err; } // The single audited cast in this layer: the server contract is asserted here, once, so // every typed api.* method (T comes from its declared return type) is checked from here up. return (r.status === 204 ? null : await r.json()) as T; } } // Multipart upload with progress. `req` rides `fetch`, which can't report upload progress, so a // file upload uses XHR instead: the caller gets a 0–100% callback as the body streams and an // optional AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab // account header, credentials, the structured-detail localization, the global error modal for // 4xx/5xx, and an `HttpError` throw — so callers handle failures exactly as they do a `req` call. // The browser sets the multipart Content-Type (with its boundary), so we deliberately don't. export function uploadReq( url: string, form: FormData, cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {}, ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", url); xhr.withCredentials = true; const active = getActiveAccount(); if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active)); if (cfg.onProgress) { xhr.upload.onprogress = (e) => { if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100)); }; } if (cfg.signal) { if (cfg.signal.aborted) return reject(new HttpError(0, ABORTED)); cfg.signal.addEventListener("abort", () => xhr.abort()); } xhr.onload = () => { if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req` if (xhr.status >= 200 && xhr.status < 300) { try { resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T); } catch { resolve(null as T); } return; } let detail: string | undefined; let detailData: unknown; try { const body = JSON.parse(xhr.responseText); if (body && typeof body.detail === "string") detail = body.detail; else if (body && body.detail && typeof body.detail === "object") { detailData = body.detail; detail = localizeDetail(body.detail); } } catch { /* no JSON body */ } const err = new HttpError(xhr.status, detail, detailData); reportFailure(err, false); reject(err); }; // A network-level failure (server unreachable) — same soft "connection lost" status as `req`. xhr.onerror = () => { markConnectivityLost(); reject(new HttpError(0, i18n.t("errors.offline.body"))); }; xhr.onabort = () => reject(new HttpError(0, ABORTED)); xhr.send(form); }); }