Files
siftlode/frontend/src/lib/api/core.ts
T
peter dcd8a9566a fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
  selected list: launched from Watch later (no Remove, by design), selecting another list under it
  made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
  editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
  "Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
  came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
  checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
  still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
  the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
  `removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
  whether or not the removed one left the queue; it now takes the second step that tells them apart.
2026-09-11 03:19:07 +02:00

344 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } 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";
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.
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 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"));
// Any playlist route whose list is gone (deleted in another tab) — or was never the caller's.
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.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 {
if (connectivityNotifId !== null) return;
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));
// `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<string, unknown>): 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 function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
notifyActiveAccountChange();
}
export function clearActiveAccount(): void {
try {
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
} catch {
/* ignore */
}
notifyActiveAccountChange();
}
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
cfg: ReqConfig = {},
): Promise<T> {
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();
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 */
}
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
// not a request the app refused — treat them like a network blip with the soft,
// self-clearing toast rather than a blocking modal the user must acknowledge.
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) {
markConnectivityLost();
} else if (r.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.
onUnauthorized?.();
} else if (cfg.quiet) {
/* caller handles the error inline — no global modal */
} else if (r.status >= 500) {
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail);
}
throw new HttpError(r.status, detail, detailData);
}
// 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<T = unknown>(
url: string,
form: FormData,
cfg: { onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
): Promise<T> {
return new Promise<T>((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 = () => {
markConnectivityRestored();
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 */
}
if (xhr.status === 401) onUnauthorized?.();
else if (xhr.status >= 500)
reportError(detail || `${i18n.t("errors.server")} (${xhr.status})`);
else if (xhr.status === 400 || xhr.status === 409 || xhr.status === 422) reportError(detail);
reject(new HttpError(xhr.status, detail, detailData));
};
// 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);
});
}