feat(ux): R11 feedback symmetry — Plex/download/rollback/deny notices

Give equal-weight actions equal-weight feedback (R11).

S1 Plex error toasts: the watched-toggles (card / season mark-all / episode /
info-panel / show mark-all) used to fail silently on 403/404/429; each now surfaces
the server reason via a shared notifyActionError() + resyncs the optimistic flip.
The playback auto-mark-watched stays intentionally silent (a mid-play toast is
noise; the next sync corrects it). New generic lib/actionErrors.ts (Plex + saved
views reuse it; notifyYouTubeActionError delegates its non-403 case to it).

S2 download-done inbox notification: worker.py emits a best-effort type=download_done
notice to the job owner on real completion (regular + edit jobs, not instant
cache-hits), rendered/localized in the inbox with an Open-Downloads link.

S3 optimistic rollbacks: SavedViewsWidget create/rename/toggle-default/remove/reorder
and the inbox revert-state no longer swallow failures — they toast + refetch to
resync (the reorder's optimistic order reverts to server truth).

S4 access-request deny: deny_invite now emails the requester (send_access_denied),
mirroring approve — no more silent limbo.
This commit is contained in:
2026-08-11 05:05:50 +02:00
parent 354b3dd10c
commit e2fe6fa286
15 changed files with 154 additions and 14 deletions
+22 -3
View File
@@ -44,6 +44,7 @@ import {
type PlexUnifiedResult,
} from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import {
@@ -223,7 +224,13 @@ export default function PlexBrowse() {
async function toggleWatched(card: PlexCard) {
const next = card.status === "watched" ? "new" : "watched";
optimisticStatus(card.id, next);
await api.plexSetState(card.id, next).catch(() => {});
// On failure surface the server's reason (403/404/429) — the toggle used to fail silently, so the
// click looked dead. The invalidate below runs either way, resyncing the optimistic flip to truth.
try {
await api.plexSetState(card.id, next);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
@@ -1012,6 +1019,8 @@ function PlexShowView({
setBusy(true);
try {
await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
@@ -1019,7 +1028,11 @@ function PlexShowView({
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markSeason(seasonRk: string, w: boolean) {
await api.plexSeasonState(seasonRk, w).catch(() => {});
try {
await api.plexSeasonState(seasonRk, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
@@ -1253,6 +1266,8 @@ function PlexSeasonView({
setBusy(true);
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally {
setBusy(false);
}
@@ -1260,7 +1275,11 @@ function PlexSeasonView({
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markEpisode(ep: PlexCard) {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
try {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}