fix(api): a write that hit a connection failure is still reported, and every failure is reported once

Deep-review round 4, the "one report per failure" contract round 3 introduced.

- `req` marked gateway (502/503/504) and network failures "already reported" because they raise
  the connection notice. But that notice is about connectivity and removes itself on the very next
  answer — which every caller's own resync refetch fetches at once — so a delete that hit a
  keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the
  durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed.
- A connection notice the user closed swallowed every later one for the rest of the visit; it is
  raised again.
- `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a
  502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true.
- A taken Plex collection name is a structured 409, localized, and the create/rename calls are
  quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone.
- The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve /
  deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag
  attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes
  a level now). The user role / suspend / delete actions had no onError at all: a user deleted in
  another tab failed silently and stayed listed — now said, and the list refetched.
This commit is contained in:
2026-09-11 23:27:48 +02:00
parent 6a92585098
commit 6d49da17fe
22 changed files with 328 additions and 137 deletions
+3 -1
View File
@@ -347,7 +347,9 @@ def _reject_duplicate_name(
.first() .first()
) )
if dup is not None and dup.rating_key != exclude_rk: if dup is not None and dup.rating_key != exclude_rk:
raise HTTPException(status_code=409, detail="A collection with this name already exists") # Structured, so the bilingual UI says it in the reader's language (it was toasted in
# Hungarian beside an English error dialog saying the same thing).
raise HTTPException(status_code=409, detail={"code": "collection", "reason": "name_taken"})
def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException: def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
@@ -155,6 +155,7 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch):
json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
) )
assert r.status_code == 409, r.text assert r.status_code == 409, r.text
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/useMe", () => ({
useMe: () => ({
id: 1,
email: "admin@x",
role: "admin",
pending_invites: 0,
pending_activations: 0,
}),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify,
}));
vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
import AdminUsers from "./AdminUsers";
import { api, type AdminUserRow } from "../lib/api";
import i18n from "../i18n";
const row = {
id: 2,
email: "gone@x",
role: "user",
is_suspended: false,
is_demo: false,
email_verified: true,
is_active: true,
} as unknown as AdminUserRow;
function answer(status: number, detail: unknown) {
return new Response(JSON.stringify({ detail }), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
notify.mockClear();
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function mount() {
const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<AdminUsers />
</QueryClientProvider>,
);
return users;
}
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
answer(404, { code: "not_found", reason: "user" }),
);
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
}),
);
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
});
it("is not toasted on top of the error dialog the request already opened", async () => {
mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
const { reportError } = await import("../lib/errorDialog");
await waitFor(() => expect(reportError).toHaveBeenCalled());
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
});
});
+20 -8
View File
@@ -38,6 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs"; import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the // Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -120,6 +121,13 @@ function UsersRoles({ me }: { me: Me }) {
n.delete(id); n.delete(id);
return n; return n;
}); });
// A failed user action: said once (notifyActionError skips what `req` already put in its dialog),
// and the list refetched — a user deleted in another tab would otherwise stay listed, every click
// on the row doing nothing.
const onGoneOrFailed = (e: unknown, fallback: string) => {
notifyActionError(e, fallback);
qc.invalidateQueries({ queryKey: qk.adminUsers() });
};
const setRole = useMutation({ const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
api.setUserRole(id, role), api.setUserRole(id, role),
@@ -129,7 +137,9 @@ function UsersRoles({ me }: { me: Me }) {
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. // Guards (last admin / self) surface via the global error dialog with the server's detail; what
// it does not show (a user deleted meanwhile, a 404) is said here — see onGoneOrFailed.
onError: (e) => onGoneOrFailed(e, t("users.roles.failed")),
}); });
const suspend = useMutation({ const suspend = useMutation({
@@ -146,7 +156,8 @@ function UsersRoles({ me }: { me: Me }) {
}); });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
// Guards (demo / self / last admin) surface via the global error dialog. // Guards (demo / self / last admin) surface via the global error dialog; the rest, as above.
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
}); });
const del = useMutation({ const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
@@ -156,6 +167,7 @@ function UsersRoles({ me }: { me: Me }) {
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
onError: (e) => onGoneOrFailed(e, t("users.delete.failed")),
}); });
const onToggle = async (u: AdminUserRow) => { const onToggle = async (u: AdminUserRow) => {
@@ -518,7 +530,7 @@ function AdminInvites() {
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) }); notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }), onError: (e) => notifyActionError(e, t("settings.invites.approveFailed")),
}); });
const deny = useMutation({ const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id), mutationFn: (id: number) => api.denyInvite(id),
@@ -526,7 +538,7 @@ function AdminInvites() {
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.denied") }); notify({ level: "success", message: t("settings.invites.denied") });
}, },
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }), onError: (e) => notifyActionError(e, t("settings.invites.denyFailed")),
}); });
const onDeny = async (i: { id: number; email: string }) => { const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({ const ok = await confirm({
@@ -630,7 +642,7 @@ function AdminInvites() {
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) }); notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }), onError: (e) => notifyActionError(e, t("settings.invites.addFailed")),
}); });
const list = invites.data ?? []; const list = invites.data ?? [];
@@ -780,7 +792,7 @@ function AdminDemo() {
qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.added", { email }) }); notify({ level: "success", message: t("settings.demo.added", { email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), onError: (e) => notifyActionError(e, t("settings.demo.addFailed")),
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id), mutationFn: (id: number) => api.removeDemoWhitelist(id),
@@ -788,7 +800,7 @@ function AdminDemo() {
qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.removed") }); notify({ level: "success", message: t("settings.demo.removed") });
}, },
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), onError: (e) => notifyActionError(e, t("settings.demo.removeFailed")),
}); });
const onRemove = async (r: { id: number; email: string }) => { const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({ const ok = await confirm({
@@ -806,7 +818,7 @@ function AdminDemo() {
level: "success", level: "success",
message: t("settings.demo.resetDone", { count: r.playlists_seeded }), message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
}), }),
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }), onError: (e) => notifyActionError(e, t("settings.demo.resetFailed")),
}); });
const rows = list.data ?? []; const rows = list.data ?? [];
+2 -1
View File
@@ -12,6 +12,7 @@ import { useConfirm } from "./ConfirmProvider";
import DataTable from "./ui/DataTable"; import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns"; import { auditColumns } from "./auditColumns";
import { notifyActionError } from "../lib/actionErrors";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run // Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via // summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
@@ -36,7 +37,7 @@ export default function AuditLog() {
qc.invalidateQueries({ queryKey: qk.adminAudit() }); qc.invalidateQueries({ queryKey: qk.adminAudit() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
}, },
onError: () => notify({ level: "error", message: t("audit.clearFailed") }), onError: (e) => notifyActionError(e, t("audit.clearFailed")),
}); });
const onClear = async () => { const onClear = async () => {
if ( if (
+3 -2
View File
@@ -6,6 +6,7 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
import ExcludedChannelList from "./ExcludedChannelList"; import ExcludedChannelList from "./ExcludedChannelList";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns"; import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -182,10 +183,10 @@ export default function Channels() {
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on // suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
// failure the tag never actually attached — so the server still suggests it and the ghost chips // failure the tag never actually attached — so the server still suggests it and the ghost chips
// must come back rather than leaving an empty cell (honest states). Surface the write failure too. // must come back rather than leaving an empty cell (honest states). Surface the write failure too.
onError: () => { onError: (e) => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() }); qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
notify({ level: "error", message: t("channels.tags.updateFailed") }); notifyActionError(e, t("channels.tags.updateFailed"));
}, },
}); });
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss // --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
+4 -3
View File
@@ -14,6 +14,7 @@ import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage"; import { LS } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -125,7 +126,7 @@ export default function ConfigPanel() {
const testEmail = useMutation({ const testEmail = useMutation({
mutationFn: () => api.testEmail(), mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }), onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
onError: () => notify({ level: "error", message: t("config.testFailed") }), onError: (e) => notifyActionError(e, t("config.testFailed")),
}); });
// Plex "Test connection": verifies the server URL + token and returns the library sections, // Plex "Test connection": verifies the server URL + token and returns the library sections,
@@ -138,9 +139,9 @@ export default function ConfigPanel() {
setPlexResult(r); setPlexResult(r);
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
}, },
onError: () => { onError: (e) => {
setPlexResult(null); setPlexResult(null);
notify({ level: "error", message: t("config.plexTestFailed") }); notifyActionError(e, t("config.plexTestFailed"));
}, },
}); });
const plexLibs = (draft["plex_libraries"] ?? "") const plexLibs = (draft["plex_libraries"] ?? "")
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react"; import { Check, Plus } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { notifyCollectionEditError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
@@ -103,9 +103,9 @@ export default function PlexCollectionEditor({
setMembers((prev) => new Set(prev).add(col.id)); setMembers((prev) => new Set(prev).add(col.id));
refresh(); refresh();
} catch (e) { } catch (e) {
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to // The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
// nameTaken rather than surfacing the server's raw English detail. // it reads in the reader's language (see localizeDetail).
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); notifyActionError(e, t("plex.collection.editFailed"));
} finally { } finally {
setBusy(null); setBusy(null);
} }
@@ -5,7 +5,7 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { showEpisodeKeys } from "../lib/plexQueue"; import { showEpisodeKeys } from "../lib/plexQueue";
import { healPlexSort, plexSortOptions } from "../lib/plexSort"; import { healPlexSort, plexSortOptions } from "../lib/plexSort";
@@ -187,8 +187,8 @@ export default function PlexCollectionView({
invalidate(); invalidate();
setRenaming(false); setRenaming(false);
} catch (e) { } catch (e) {
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken. // The backend enforces per-library name uniqueness — a taken name comes back localized.
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); notifyActionError(e, t("plex.collection.editFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
+3 -2
View File
@@ -25,6 +25,7 @@ import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip"; import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
const POLL_MS = 4000; const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks. // While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -375,14 +376,14 @@ export default function Scheduler() {
const pauseResume = useMutation({ const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }), onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")),
}); });
const intervalMut = useMutation({ const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) => mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes), api.updateSchedulerJob(v.jobId, v.minutes),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }), onError: (e) => notifyActionError(e, t("scheduler.intervalFailed")),
}); });
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the // Manual "run now" triggers. The wrapper runs in a background thread server-side; the
+2 -1
View File
@@ -7,6 +7,7 @@ import clsx from "clsx";
import Modal from "./ui/Modal"; import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
@@ -39,7 +40,7 @@ function UserShare({ job }: { job: DownloadJob }) {
setQ(""); setQ("");
setOpen(false); setOpen(false);
}, },
onError: () => notify({ level: "error", message: t("downloads.share.failed") }), onError: (e) => notifyActionError(e, t("downloads.share.failed")),
}); });
return ( return (
+2 -1
View File
@@ -15,6 +15,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
import Tabs from "./ui/Tabs"; import Tabs from "./ui/Tabs";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
const RANGES = [7, 30, 90] as const; const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system"; type StatsTab = "overview" | "system";
@@ -85,7 +86,7 @@ function Overview({ me }: { me: Me }) {
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
}); });
}, },
onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }), onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")),
}); });
return ( return (
+2 -1
View File
@@ -10,6 +10,7 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form"; import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { notifyActionError } from "../lib/actionErrors";
// mm:ss.d timecode <-> seconds. // mm:ss.d timecode <-> seconds.
function tc(s: number): string { function tc(s: number): string {
@@ -322,7 +323,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
notify({ level: "success", message: t("editor.created", { count: files }) }); notify({ level: "success", message: t("editor.created", { count: files }) });
onClose(); onClose();
}, },
onError: () => notify({ level: "error", message: t("editor.failed") }), onError: (e) => notifyActionError(e, t("editor.failed")),
}); });
const sel = segments.find((s) => s.id === selId) ?? segments[0]; const sel = segments.find((s) => s.id === selId) ?? segments[0];
+4 -3
View File
@@ -6,7 +6,7 @@ import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyCollectionEditError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile"; import PosterTile from "./PosterTile";
@@ -95,8 +95,9 @@ function NewCollectionPanel({
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
onCreated(col.id); onCreated(col.id);
} catch (e) { } catch (e) {
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken. // The backend is authoritative on per-library name uniqueness — a taken name comes back
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); // structured, and so localized (see localizeDetail).
notifyActionError(e, t("plex.collection.editFailed"));
setBusy(false); setBusy(false);
} }
} }
+8 -10
View File
@@ -1,8 +1,8 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { alreadyReported, api, HttpError, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName"; import { playlistName } from "../lib/playlistName";
import { useChoice } from "./ConfirmProvider"; import { useChoice } from "./ConfirmProvider";
@@ -71,14 +71,12 @@ export function usePlaylistActions() {
} catch (err) { } catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket // Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side — unless // "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side — unless
// `req` already put it on screen (the error dialog, the connection notice): not twice. // `req` already put it in its error dialog: not twice (notifyActionError checks).
if (!alreadyReported(err)) notifyActionError(
notify({ err,
level: "warning", onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"),
message: "warning",
(err instanceof HttpError && err.detail) || );
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
} }
qc.removeQueries({ queryKey: qk.playlist(pl.id) }); qc.removeQueries({ queryKey: qk.playlist(pl.id) });
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
+6 -3
View File
@@ -39,7 +39,8 @@
"promoteTitle": "Make admin?", "promoteTitle": "Make admin?",
"demoteTitle": "Remove admin?", "demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?", "promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user." "demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user.",
"failed": "Couldn't change the role."
}, },
"suspend": { "suspend": {
"action": "Suspend", "action": "Suspend",
@@ -51,14 +52,16 @@
"done": "{{email}} suspended", "done": "{{email}} suspended",
"undone": "{{email}} unsuspended", "undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.", "demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account." "selfLocked": "You can't suspend your own account.",
"failed": "Couldn't change the suspension."
}, },
"delete": { "delete": {
"action": "Delete", "action": "Delete",
"locked": "This account can't be deleted here.", "locked": "This account can't be deleted here.",
"title": "Delete account?", "title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.", "confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted" "done": "{{email}} deleted",
"failed": "Couldn't delete the account."
}, },
"account": { "account": {
"access": "Access", "access": "Access",
+6 -3
View File
@@ -39,7 +39,8 @@
"promoteTitle": "Adminná teszed?", "promoteTitle": "Adminná teszed?",
"demoteTitle": "Elveszed az admin jogot?", "demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?", "promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
"demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz." "demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz.",
"failed": "Nem sikerült módosítani a szerepkört."
}, },
"suspend": { "suspend": {
"action": "Felfüggeszt", "action": "Felfüggeszt",
@@ -51,14 +52,16 @@
"done": "{{email}} felfüggesztve", "done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva", "undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demo fiók nem függeszthető fel.", "demoLocked": "A demo fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel." "selfLocked": "A saját fiókodat nem függesztheted fel.",
"failed": "Nem sikerült módosítani a felfüggesztést."
}, },
"delete": { "delete": {
"action": "Törlés", "action": "Törlés",
"locked": "Ez a fiók itt nem törölhető.", "locked": "Ez a fiók itt nem törölhető.",
"title": "Törlöd a fiókot?", "title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.", "confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve" "done": "{{email}} törölve",
"failed": "Nem sikerült törölni a fiókot."
}, },
"account": { "account": {
"access": "Hozzáférés", "access": "Hozzáférés",
+25 -16
View File
@@ -5,28 +5,37 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the toast sink so we can assert exactly what each error maps to. // Spy on the toast sink so we can assert exactly what each error maps to.
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api"; import { HttpError, api } from "./api";
import { notify } from "./notifications"; import { notify } from "./notifications";
import { notifyActionError, notifyCollectionEditError } from "./actionErrors"; import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear()); beforeEach(() => {
notifyMock.mockClear();
vi.mocked(reportError).mockClear();
});
describe("notifyCollectionEditError", () => { describe("a Plex collection name that is already taken", () => {
it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => { it("is reported once, in the reader's language", async () => {
notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN"); // The 409 was a bare English string: the global error dialog showed it in English, and the
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" }); // editor's own toast said the same thing again, translated (deep-review round 4).
}); vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ detail: { code: "collection", reason: "name_taken" } }), {
it("falls back to the server detail for a non-409 HttpError", () => { status: 409,
notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN"); headers: { "content-type": "application/json" },
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" }); }),
}); );
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
it("falls back to the generic edit-failed message for a non-HttpError", () => { notifyMock.mockClear();
notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN"); notifyActionError(err, "EDIT_FAILED");
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" }); expect(notifyMock.mock.calls).toEqual([
[{ level: "error", message: i18n.t("plex.collection.nameTaken") }],
]);
expect(reportError).not.toHaveBeenCalled();
}); });
}); });
+7 -22
View File
@@ -10,30 +10,15 @@ import { notify } from "./notifications";
* the Plex watched-toggles and the saved-view ops, and delegated to by `notifyYouTubeActionError` * the Plex watched-toggles and the saved-view ops, and delegated to by `notifyYouTubeActionError`
* for its non-403 case. * for its non-403 case.
*/ */
export function notifyActionError(err: unknown, fallbackMessage: string): void { export function notifyActionError(
// Already on screen (`req` opened the error dialog, or raised the connection notice): a toast on err: unknown,
fallbackMessage: string,
level: "error" | "warning" = "error",
): void {
// Already on screen (`req` opened the error dialog, or the tab is being signed out): a toast on
// top reported one fault twice. Playlists filtered this by status by hand; the watched toggles, // top reported one fault twice. Playlists filtered this by status by hand; the watched toggles,
// the saved views and a playlist delete did not. // the saved views and a playlist delete did not.
if (alreadyReported(err)) return; if (alreadyReported(err)) return;
const detail = err instanceof HttpError ? err.detail : null; const detail = err instanceof HttpError ? err.detail : null;
notify({ level: "error", message: detail || fallbackMessage }); notify({ level, message: detail || fallbackMessage });
}
/**
* Collection write errors. The backend enforces per-library name uniqueness and returns 409 on a
* duplicate; localize that specific case to the friendly `nameTaken` copy (notifyActionError would
* otherwise surface the server's raw English detail). Everything else falls back to the generic
* edit-failed handling (which still prefers the server's reason when present). Both args are already
* translated so this stays i18n-agnostic and unit-testable.
*/
export function notifyCollectionEditError(
err: unknown,
editFailedMessage: string,
nameTakenMessage: string,
): void {
if (err instanceof HttpError && err.status === 409) {
notify({ level: "error", message: nameTakenMessage });
return;
}
notifyActionError(err, editFailedMessage);
} }
+53 -5
View File
@@ -6,14 +6,15 @@
// each lifecycle branch. // each lifecycle branch.
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
const { reportError, notify, remove } = vi.hoisted(() => ({ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
reportError: vi.fn(), reportError: vi.fn(),
notify: vi.fn(() => 1), notify: vi.fn(() => 1),
remove: vi.fn(), remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []),
})); }));
vi.mock("../errorDialog", () => ({ reportError })); vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove })); vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({ vi.mock("../activeAccount", () => ({
activeAccountId: () => null, activeAccountId: () => null,
@@ -54,6 +55,9 @@ class FakeXHR {
beforeEach(() => { beforeEach(() => {
reportError.mockReset(); reportError.mockReset();
notify.mockReset(); notify.mockReset();
remove.mockReset();
getActiveToasts.mockReset();
getActiveToasts.mockReturnValue([]);
setUnauthorizedHandler(null); setUnauthorizedHandler(null);
vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest); vi.stubGlobal("XMLHttpRequest", FakeXHR as unknown as typeof XMLHttpRequest);
}); });
@@ -167,17 +171,45 @@ describe("req — what it has already put in front of the user", () => {
expect(alreadyReported(err)).toBe(true); expect(alreadyReported(err)).toBe(true);
}); });
it("marks a request that never reached the server (the connection notice says it)", async () => { // The connection notice says the server is unreachable — not that THIS action failed — and it
// removes itself on the very next answer (the caller's own resync refetch). Marked as reported, a
// delete that hit a keepalive reset flashed the notice and was then reported nowhere at all.
it("raises the connection notice for a request that never reached the server, and leaves the failed action to the caller", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e); const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
expect(notify).toHaveBeenCalled(); expect(notify).toHaveBeenCalled();
expect(alreadyReported(err)).toBe(true); expect(alreadyReported(err)).toBe(false);
});
it.each([502, 503, 504])(
"raises the connection notice for a %s, and leaves the failed action to the caller",
async (status) => {
answer(status);
const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e);
expect(notify).toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
expect(err).toMatchObject({ status });
expect(alreadyReported(err)).toBe(false);
},
);
it("raises the connection notice again after the user closed it", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
notify.mockReturnValue(7);
await req("/api/x", { method: "POST" }).catch(() => {});
getActiveToasts.mockReturnValue([{ id: 7 }]); // still on screen: one notice, not a stack
notify.mockClear();
await req("/api/x", { method: "POST" }).catch(() => {});
expect(notify).not.toHaveBeenCalled();
getActiveToasts.mockReturnValue([]); // closed with its ✕
await req("/api/x", { method: "POST" }).catch(() => {});
expect(notify).toHaveBeenCalledOnce();
}); });
it("marks what the upload path reported the same way", async () => { it("marks what the upload path reported the same way", async () => {
const dead = uploadReq("/u", form()); const dead = uploadReq("/u", form());
FakeXHR.last.onerror!(); FakeXHR.last.onerror!();
expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(true); expect(alreadyReported(await dead.catch((e: unknown) => e))).toBe(false);
const refused = uploadReq("/u", form()); const refused = uploadReq("/u", form());
FakeXHR.last.status = 500; FakeXHR.last.status = 500;
FakeXHR.last.onload!(); FakeXHR.last.onload!();
@@ -187,4 +219,20 @@ describe("req — what it has already put in front of the user", () => {
FakeXHR.last.onload!(); FakeXHR.last.onload!();
expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false); expect(alreadyReported(await gone.catch((e: unknown) => e))).toBe(false);
}); });
it("treats a gateway error on the upload path as the connection notice, not a blocking dialog", async () => {
// The upload path kept its own copy of this policy, and it had drifted: a 502 opened the
// blocking "Server error (502)" dialog and cleared a connection notice that was still true.
notify.mockReturnValue(9);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch"));
await req("/api/x", { method: "POST" }).catch(() => {}); // a notice is up
getActiveToasts.mockReturnValue([{ id: 9 }]);
remove.mockClear();
const p = uploadReq("/u", form());
FakeXHR.last.status = 502;
FakeXHR.last.onload!();
await expect(p).rejects.toMatchObject({ status: 502 });
expect(reportError).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
}); });
+54 -40
View File
@@ -2,7 +2,7 @@
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing, // 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, // 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. // so the runtime graph is a clean barrel → domain → core with no cycles.
import { notify, remove } from "../notifications"; import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { formatBytes } from "../format"; import { formatBytes } from "../format";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
@@ -14,9 +14,15 @@ import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "
* field nobody ever filled (E9.5 S6 → S7). */ * field nobody ever filled (E9.5 S6 → S7). */
export const uiLang = (): string => i18n.language.split("-")[0] || "en"; export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// Errors `req` has already put in front of the user — the blocking error dialog, or the connection // Errors `req` has already put in front of the user for good — the blocking error dialog, or the
// notice. A caller that toasts a failed action as well reported the same fault twice (a modal AND a // sign-out a 401 triggers. A caller that toasts a failed action as well reported the same fault twice
// toast); `notifyActionError` asks this instead of each caller filtering statuses by hand. // (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).
const reported = new WeakSet<object>(); const reported = new WeakSet<object>();
export function alreadyReported(err: unknown): boolean { export function alreadyReported(err: unknown): boolean {
return typeof err === "object" && err !== null && reported.has(err); return typeof err === "object" && err !== null && reported.has(err);
@@ -90,6 +96,11 @@ function localizeDetail(d: unknown): string {
// unknown reason falls back to the plain failure rather than printing a key. // unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move") if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed")); 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, …). // 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")); if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's // Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
@@ -109,7 +120,11 @@ function localizeDetail(d: unknown): string {
// bursts of concurrent failures from stacking up — there's only ever one live handle. // bursts of concurrent failures from stacking up — there's only ever one live handle.
let connectivityNotifId: number | null = null; let connectivityNotifId: number | null = null;
function markConnectivityLost(): void { function markConnectivityLost(): void {
if (connectivityNotifId !== null) return; // 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({ connectivityNotifId = notify({
level: "error", level: "error",
title: i18n.t("errors.offline.title"), title: i18n.t("errors.offline.title"),
@@ -129,6 +144,35 @@ function markConnectivityRestored(): void {
const RETRIABLE_GATEWAY = new Set([502, 503, 504]); const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms)); 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? // `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 // 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. // are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
@@ -235,8 +279,7 @@ export async function req<T = unknown>(
await delay(250); await delay(250);
continue; continue;
} }
markConnectivityLost(); markConnectivityLost(); // the connection notice; the failed action is still the caller's
if (typeof e === "object" && e !== null) reported.add(e);
throw e; throw e;
} }
@@ -266,30 +309,8 @@ export async function req<T = unknown>(
} catch { } catch {
/* no JSON body */ /* 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.
const err = new HttpError(r.status, detail, detailData); const err = new HttpError(r.status, detail, detailData);
if (RETRIABLE_GATEWAY.has(r.status)) { reportFailure(err, !!cfg.quiet);
markConnectivityLost();
reported.add(err);
} 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 —
// but handled all the same, so not the caller's to toast either.
onUnauthorized?.();
reported.add(err);
} 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})`);
reported.add(err);
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail);
reported.add(err);
}
throw err; throw err;
} }
// The single audited cast in this layer: the server contract is asserted here, once, so // The single audited cast in this layer: the server contract is asserted here, once, so
@@ -325,7 +346,7 @@ export function uploadReq<T = unknown>(
cfg.signal.addEventListener("abort", () => xhr.abort()); cfg.signal.addEventListener("abort", () => xhr.abort());
} }
xhr.onload = () => { xhr.onload = () => {
markConnectivityRestored(); if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
try { try {
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T); resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
@@ -347,20 +368,13 @@ export function uploadReq<T = unknown>(
/* no JSON body */ /* no JSON body */
} }
const err = new HttpError(xhr.status, detail, detailData); const err = new HttpError(xhr.status, detail, detailData);
if (xhr.status === 401) onUnauthorized?.(); reportFailure(err, false);
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);
if (xhr.status === 401 || xhr.status >= 500 || [400, 409, 422].includes(xhr.status))
reported.add(err); // as in `req`: already on screen, not the caller's to toast again
reject(err); reject(err);
}; };
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`. // A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
xhr.onerror = () => { xhr.onerror = () => {
markConnectivityLost(); markConnectivityLost();
const err = new HttpError(0, i18n.t("errors.offline.body")); reject(new HttpError(0, i18n.t("errors.offline.body")));
reported.add(err);
reject(err);
}; };
xhr.onabort = () => reject(new HttpError(0, "aborted")); xhr.onabort = () => reject(new HttpError(0, "aborted"));
xhr.send(form); xhr.send(form);
+13 -8
View File
@@ -505,15 +505,19 @@ export const plexApi = {
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`); return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
}, },
// --- Collection editing (admin only; writes back to Plex) --- // --- Collection editing (admin only; writes back to Plex) ---
// `quiet`: the editor reports a failure itself, as a toast beside the name being typed (a taken
// name comes back structured and localized — see localizeDetail). Not quiet, it was reported twice:
// the global error dialog in English AND the editor's toast.
plexCreateCollection: ( plexCreateCollection: (
library: string, library: string,
title: string, title: string,
item_rating_key: string, item_rating_key: string,
): Promise<PlexCollection> => ): Promise<PlexCollection> =>
req(`/api/plex/collections`, { req(
method: "POST", `/api/plex/collections`,
body: JSON.stringify({ library, title, item_rating_key }), { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) },
}), { quiet: true },
),
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> => plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
method: "POST", method: "POST",
@@ -523,10 +527,11 @@ export const plexApi = {
method: "DELETE", method: "DELETE",
}), }),
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> => plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { req(
method: "PATCH", `/api/plex/collections/${encodeURIComponent(rk)}`,
body: JSON.stringify({ title }), { method: "PATCH", body: JSON.stringify({ title }) },
}), { quiet: true }, // as plexCreateCollection
),
plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> => plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>