From 6d49da17fe8ef74448f95cd903a1a4f3bc75b4aa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 23:27:08 +0200 Subject: [PATCH] fix(api): a write that hit a connection failure is still reported, and every failure is reported once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/plex/collections.py | 4 +- backend/tests/test_plex_collections_filter.py | 1 + .../src/components/AdminUsers.dom.test.tsx | 103 ++++++++++++++++++ frontend/src/components/AdminUsers.tsx | 28 +++-- frontend/src/components/AuditLog.tsx | 3 +- frontend/src/components/Channels.tsx | 5 +- frontend/src/components/ConfigPanel.tsx | 7 +- .../src/components/PlexCollectionEditor.tsx | 8 +- .../src/components/PlexCollectionView.tsx | 6 +- frontend/src/components/Scheduler.tsx | 5 +- frontend/src/components/ShareDialog.tsx | 3 +- frontend/src/components/Stats.tsx | 3 +- frontend/src/components/VideoEditor.tsx | 3 +- frontend/src/components/plexBrowseLists.tsx | 7 +- frontend/src/components/usePlaylistActions.ts | 18 ++- frontend/src/i18n/locales/en/users.json | 9 +- frontend/src/i18n/locales/hu/users.json | 9 +- frontend/src/lib/actionErrors.test.ts | 41 ++++--- frontend/src/lib/actionErrors.ts | 29 ++--- frontend/src/lib/api/core.test.ts | 58 +++++++++- frontend/src/lib/api/core.ts | 94 +++++++++------- frontend/src/lib/api/plex.ts | 21 ++-- 22 files changed, 328 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/AdminUsers.dom.test.tsx diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index ffcfa397..5719cf3a 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -347,7 +347,9 @@ def _reject_duplicate_name( .first() ) 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: diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index 67707360..36668044 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -155,6 +155,7 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch): json={"library": "1", "title": "marvel", "item_rating_key": "m1"}, ) assert r.status_code == 409, r.text + assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"} finally: app.dependency_overrides.clear() diff --git a/frontend/src/components/AdminUsers.dom.test.tsx b/frontend/src/components/AdminUsers.dom.test.tsx new file mode 100644 index 00000000..d94ad5e8 --- /dev/null +++ b/frontend/src/components/AdminUsers.dom.test.tsx @@ -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(); + 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()), + 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( + + + , + ); + 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([]); + }); +}); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 55333a27..90ab5f27 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -38,6 +38,7 @@ import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./ui/Tabs"; import { PageToolbar } from "./ui/PageShell"; import { useMe } from "../lib/useMe"; +import { notifyActionError } from "../lib/actionErrors"; // 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 @@ -120,6 +121,13 @@ function UsersRoles({ me }: { me: Me }) { n.delete(id); 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({ mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => api.setUserRole(id, role), @@ -129,7 +137,9 @@ function UsersRoles({ me }: { me: Me }) { notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, 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({ @@ -146,7 +156,8 @@ function UsersRoles({ me }: { me: Me }) { }); }, 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({ 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 }) }); }, onSettled: (_d, _e, vars) => endPending(vars.id), + onError: (e) => onGoneOrFailed(e, t("users.delete.failed")), }); const onToggle = async (u: AdminUserRow) => { @@ -518,7 +530,7 @@ function AdminInvites() { refresh(); 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({ mutationFn: (id: number) => api.denyInvite(id), @@ -526,7 +538,7 @@ function AdminInvites() { refresh(); 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 ok = await confirm({ @@ -630,7 +642,7 @@ function AdminInvites() { refresh(); 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 ?? []; @@ -780,7 +792,7 @@ function AdminDemo() { qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); 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({ mutationFn: (id: number) => api.removeDemoWhitelist(id), @@ -788,7 +800,7 @@ function AdminDemo() { qc.invalidateQueries({ queryKey: qk.demoWhitelist() }); 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 ok = await confirm({ @@ -806,7 +818,7 @@ function AdminDemo() { level: "success", 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 ?? []; diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index e382a24a..0fa2618e 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -12,6 +12,7 @@ import { useConfirm } from "./ConfirmProvider"; import DataTable from "./ui/DataTable"; import { LoadingState, StateMessage } from "./ui/QueryState"; 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 // 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() }); 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 () => { if ( diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 54799ea6..909c7874 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -6,6 +6,7 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr import ExcludedChannelList from "./ExcludedChannelList"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { Sparkles } from "lucide-react"; +import { notifyActionError } from "../lib/actionErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { buildChannelColumns, applySyncFilter } from "./channelColumns"; @@ -182,10 +183,10 @@ export default function Channels() { // 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 // 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.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 diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 0b8b35ee..a0300760 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -14,6 +14,7 @@ import { PageToolbar } from "./ui/PageShell"; import { Switch } from "./ui/form"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar"; import { LS } from "../lib/storage"; +import { notifyActionError } from "../lib/actionErrors"; // 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 @@ -125,7 +126,7 @@ export default function ConfigPanel() { const testEmail = useMutation({ mutationFn: () => api.testEmail(), 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, @@ -138,9 +139,9 @@ export default function ConfigPanel() { setPlexResult(r); notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) }); }, - onError: () => { + onError: (e) => { setPlexResult(null); - notify({ level: "error", message: t("config.plexTestFailed") }); + notifyActionError(e, t("config.plexTestFailed")); }, }); const plexLibs = (draft["plex_libraries"] ?? "") diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 6182d8b5..ab5dac28 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Plus } from "lucide-react"; import { api } from "../lib/api"; -import { notifyCollectionEditError } from "../lib/actionErrors"; +import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; @@ -103,9 +103,9 @@ export default function PlexCollectionEditor({ setMembers((prev) => new Set(prev).add(col.id)); refresh(); } catch (e) { - // The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to - // nameTaken rather than surfacing the server's raw English detail. - notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); + // The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so + // it reads in the reader's language (see localizeDetail). + notifyActionError(e, t("plex.collection.editFailed")); } finally { setBusy(null); } diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 02743cdc..8b9d88d4 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -5,7 +5,7 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; -import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; +import { notifyActionError } from "../lib/actionErrors"; import { notify } from "../lib/notifications"; import { showEpisodeKeys } from "../lib/plexQueue"; import { healPlexSort, plexSortOptions } from "../lib/plexSort"; @@ -187,8 +187,8 @@ export default function PlexCollectionView({ invalidate(); setRenaming(false); } catch (e) { - // The backend enforces per-library name uniqueness — localize its 409 to nameTaken. - notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); + // The backend enforces per-library name uniqueness — a taken name comes back localized. + notifyActionError(e, t("plex.collection.editFailed")); } finally { setBusy(false); } diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index eb5cdd5b..b1d779c0 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -25,6 +25,7 @@ import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; +import { notifyActionError } from "../lib/actionErrors"; const POLL_MS = 4000; // 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({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), - onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }), + onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")), }); const intervalMut = useMutation({ mutationFn: (v: { jobId: string; minutes: number }) => api.updateSchedulerJob(v.jobId, v.minutes), 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 diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 66b8a85f..67ec04b5 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -7,6 +7,7 @@ import clsx from "clsx"; import Modal from "./ui/Modal"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; +import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { inputCls } from "./ui/form"; @@ -39,7 +40,7 @@ function UserShare({ job }: { job: DownloadJob }) { setQ(""); setOpen(false); }, - onError: () => notify({ level: "error", message: t("downloads.share.failed") }), + onError: (e) => notifyActionError(e, t("downloads.share.failed")), }); return ( diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 7efc4342..d07d62c4 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -15,6 +15,7 @@ import FilterChipGroup from "./ui/FilterChipGroup"; import Tabs from "./ui/Tabs"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; +import { notifyActionError } from "../lib/actionErrors"; const RANGES = [7, 30, 90] as const; type StatsTab = "overview" | "system"; @@ -85,7 +86,7 @@ function Overview({ me }: { me: Me }) { 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 ( diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 8b5f563e..ec5c33a3 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -10,6 +10,7 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; import { inputCls, btnCls } from "./ui/form"; import { Button } from "./ui/Button"; +import { notifyActionError } from "../lib/actionErrors"; // mm:ss.d timecode <-> seconds. 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 }) }); 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]; diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index 07187a74..036d78c8 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -6,7 +6,7 @@ import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api"; import { formatRuntime } from "../lib/format"; -import { notifyCollectionEditError } from "../lib/actionErrors"; +import { notifyActionError } from "../lib/actionErrors"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState, StateMessage } from "./ui/QueryState"; import PosterTile from "./PosterTile"; @@ -95,8 +95,9 @@ function NewCollectionPanel({ const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library onCreated(col.id); } catch (e) { - // The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken. - notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken")); + // The backend is authoritative on per-library name uniqueness — a taken name comes back + // structured, and so localized (see localizeDetail). + notifyActionError(e, t("plex.collection.editFailed")); setBusy(false); } } diff --git a/frontend/src/components/usePlaylistActions.ts b/frontend/src/components/usePlaylistActions.ts index 541f376e..731a7589 100644 --- a/frontend/src/components/usePlaylistActions.ts +++ b/frontend/src/components/usePlaylistActions.ts @@ -1,8 +1,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { qk } from "../lib/queryKeys"; -import { alreadyReported, api, HttpError, type Playlist } from "../lib/api"; -import { notify } from "../lib/notifications"; +import { api, type Playlist } from "../lib/api"; +import { notifyActionError } from "../lib/actionErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { playlistName } from "../lib/playlistName"; import { useChoice } from "./ConfirmProvider"; @@ -71,14 +71,12 @@ export function usePlaylistActions() { } catch (err) { // 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 - // `req` already put it on screen (the error dialog, the connection notice): not twice. - if (!alreadyReported(err)) - notify({ - level: "warning", - message: - (err instanceof HttpError && err.detail) || - (onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")), - }); + // `req` already put it in its error dialog: not twice (notifyActionError checks). + notifyActionError( + err, + onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed"), + "warning", + ); } qc.removeQueries({ queryKey: qk.playlist(pl.id) }); qc.invalidateQueries({ queryKey: qk.playlists() }); diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 1c2e4ff4..99f0ebec 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -39,7 +39,8 @@ "promoteTitle": "Make admin?", "demoteTitle": "Remove admin?", "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": { "action": "Suspend", @@ -51,14 +52,16 @@ "done": "{{email}} suspended", "undone": "{{email}} unsuspended", "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": { "action": "Delete", "locked": "This account can't be deleted here.", "title": "Delete account?", "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": { "access": "Access", diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 80a6df7e..95e2591c 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -39,7 +39,8 @@ "promoteTitle": "Adminná teszed?", "demoteTitle": "Elveszed az admin jogot?", "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": { "action": "Felfüggeszt", @@ -51,14 +52,16 @@ "done": "{{email}} felfüggesztve", "undone": "{{email}} felfüggesztése feloldva", "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": { "action": "Törlés", "locked": "Ez a fiók itt nem törölhető.", "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ó.", - "done": "{{email}} törölve" + "done": "{{email}} törölve", + "failed": "Nem sikerült törölni a fiókot." }, "account": { "access": "Hozzáférés", diff --git a/frontend/src/lib/actionErrors.test.ts b/frontend/src/lib/actionErrors.test.ts index 721f9ab6..1f64c5b7 100644 --- a/frontend/src/lib/actionErrors.test.ts +++ b/frontend/src/lib/actionErrors.test.ts @@ -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. vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); +vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); import { HttpError, api } from "./api"; 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); -beforeEach(() => notifyMock.mockClear()); +beforeEach(() => { + notifyMock.mockClear(); + vi.mocked(reportError).mockClear(); +}); -describe("notifyCollectionEditError", () => { - it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => { - notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN"); - expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" }); - }); - - it("falls back to the server detail for a non-409 HttpError", () => { - notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN"); - expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" }); - }); - - it("falls back to the generic edit-failed message for a non-HttpError", () => { - notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN"); - expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" }); +describe("a Plex collection name that is already taken", () => { + it("is reported once, in the reader's language", async () => { + // The 409 was a bare English string: the global error dialog showed it in English, and the + // 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" } }), { + status: 409, + headers: { "content-type": "application/json" }, + }), + ); + const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e); + notifyMock.mockClear(); + notifyActionError(err, "EDIT_FAILED"); + expect(notifyMock.mock.calls).toEqual([ + [{ level: "error", message: i18n.t("plex.collection.nameTaken") }], + ]); + expect(reportError).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/lib/actionErrors.ts b/frontend/src/lib/actionErrors.ts index 93fd8a6b..e2ad1d26 100644 --- a/frontend/src/lib/actionErrors.ts +++ b/frontend/src/lib/actionErrors.ts @@ -10,30 +10,15 @@ import { notify } from "./notifications"; * the Plex watched-toggles and the saved-view ops, and delegated to by `notifyYouTubeActionError` * for its non-403 case. */ -export function notifyActionError(err: unknown, fallbackMessage: string): void { - // Already on screen (`req` opened the error dialog, or raised the connection notice): a toast on +export function notifyActionError( + 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, // the saved views and a playlist delete did not. if (alreadyReported(err)) return; const detail = err instanceof HttpError ? err.detail : null; - notify({ level: "error", 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); + notify({ level, message: detail || fallbackMessage }); } diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index ca5c2b79..dc614ed8 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -6,14 +6,15 @@ // each lifecycle branch. import { describe, it, expect, beforeEach, vi } from "vitest"; -const { reportError, notify, remove } = vi.hoisted(() => ({ +const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({ reportError: vi.fn(), notify: vi.fn(() => 1), remove: vi.fn(), + getActiveToasts: vi.fn((): { id: number }[] => []), })); 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("../activeAccount", () => ({ activeAccountId: () => null, @@ -54,6 +55,9 @@ class FakeXHR { beforeEach(() => { reportError.mockReset(); notify.mockReset(); + remove.mockReset(); + getActiveToasts.mockReset(); + getActiveToasts.mockReturnValue([]); setUnauthorizedHandler(null); 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); }); - 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")); const err = await req("/api/x", { method: "POST" }).catch((e: unknown) => e); 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 () => { const dead = uploadReq("/u", form()); 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()); FakeXHR.last.status = 500; FakeXHR.last.onload!(); @@ -187,4 +219,20 @@ describe("req — what it has already put in front of the user", () => { FakeXHR.last.onload!(); 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(); + }); }); diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index 7d598e92..2afc3e66 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -2,7 +2,7 @@ // 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 { getActiveToasts, notify, remove } from "../notifications"; import i18n from "../../i18n"; import { formatBytes } from "../format"; import { reportError } from "../errorDialog"; @@ -14,9 +14,15 @@ import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from " * 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 — the blocking error dialog, or the connection -// notice. 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. +// 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). const reported = new WeakSet(); export function alreadyReported(err: unknown): boolean { 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. 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")); // 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. let connectivityNotifId: number | null = null; 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({ level: "error", title: i18n.t("errors.offline.title"), @@ -129,6 +144,35 @@ function markConnectivityRestored(): void { 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. @@ -235,8 +279,7 @@ export async function req( await delay(250); continue; } - markConnectivityLost(); - if (typeof e === "object" && e !== null) reported.add(e); + markConnectivityLost(); // the connection notice; the failed action is still the caller's throw e; } @@ -266,30 +309,8 @@ export async function req( } 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. const err = new HttpError(r.status, detail, detailData); - if (RETRIABLE_GATEWAY.has(r.status)) { - 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); - } + reportFailure(err, !!cfg.quiet); throw err; } // The single audited cast in this layer: the server contract is asserted here, once, so @@ -325,7 +346,7 @@ export function uploadReq( cfg.signal.addEventListener("abort", () => xhr.abort()); } xhr.onload = () => { - markConnectivityRestored(); + 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); @@ -347,20 +368,13 @@ export function uploadReq( /* no JSON body */ } const err = new HttpError(xhr.status, detail, detailData); - 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); - 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 + reportFailure(err, false); reject(err); }; // A network-level failure (server unreachable) — same soft "connection lost" status as `req`. xhr.onerror = () => { markConnectivityLost(); - const err = new HttpError(0, i18n.t("errors.offline.body")); - reported.add(err); - reject(err); + reject(new HttpError(0, i18n.t("errors.offline.body"))); }; xhr.onabort = () => reject(new HttpError(0, "aborted")); xhr.send(form); diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 9be6b783..5042a861 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -505,15 +505,19 @@ export const plexApi = { return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`); }, // --- 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: ( library: string, title: string, item_rating_key: string, ): Promise => - req(`/api/plex/collections`, { - method: "POST", - body: JSON.stringify({ library, title, item_rating_key }), - }), + req( + `/api/plex/collections`, + { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }, + { quiet: true }, + ), plexCollectionAddItem: (rk: string, itemRk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST", @@ -523,10 +527,11 @@ export const plexApi = { method: "DELETE", }), plexRenameCollection: (rk: string, title: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}`, { - method: "PATCH", - body: JSON.stringify({ title }), - }), + req( + `/api/plex/collections/${encodeURIComponent(rk)}`, + { method: "PATCH", body: JSON.stringify({ title }) }, + { quiet: true }, // as plexCreateCollection + ), plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), plexSetCollectionEditable: (rk: string, editable: boolean): Promise =>