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
+13
View File
@@ -83,6 +83,19 @@ def send_access_approved(email: str) -> bool:
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
def send_access_denied(email: str) -> bool:
admin = _admin_contact()
body = (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
"this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
)
return _send([email], "About your Siftlode access request", body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool: def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = ( line = (
"You've been granted admin access on Siftlode — you can now manage settings, the " "You've been granted admin access on Siftlode — you can now manage settings, the "
+6 -1
View File
@@ -105,10 +105,15 @@ def approve_invite(
@router.post("/invites/{invite_id}/deny") @router.post("/invites/{invite_id}/deny")
def deny_invite( def deny_invite(
invite_id: int, invite_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
return _serialize(_decide(db, invite_id, admin, approved=False)) # Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
# account is activated, so this is an email only (the requester has no in-app inbox yet).
inv = _decide(db, invite_id, admin, approved=False)
background.add_task(email_mod.send_access_denied, inv.email)
return _serialize(inv)
class AddInviteIn(BaseModel): class AddInviteIn(BaseModel):
+23
View File
@@ -34,10 +34,31 @@ from app.downloads import edit as editmod
from app.downloads import errors as dlerrors from app.downloads import errors as dlerrors
from app.downloads import formats, quota, service, storage from app.downloads import formats, quota, service, storage
from app.models import DownloadJob, MediaAsset from app.models import DownloadJob, MediaAsset
from app.notifications import create_notification
from app.titles import normalize_title from app.titles import normalize_title
log = logging.getLogger("siftlode.worker") log = logging.getLogger("siftlode.worker")
def _notify_download_done(db, job: DownloadJob | None, title: str | None) -> None:
"""Post a best-effort 'download ready' notice to the owner's inbox — downloads used to slide to
Done silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry).
A notification failure must never mask the completed download, so it is isolated + rolled back
(mirrors scheduler._notify_done). English title is a fallback; the inbox localizes from `data`."""
if not job:
return
try:
create_notification(
db,
user_id=job.user_id,
type="download_done",
title=f"Download ready: {title}" if title else "Download ready",
data={"title": title},
)
except Exception:
db.rollback()
log.exception("failed to write download-done notification for job %s", job.id)
_stop = threading.Event() _stop = threading.Event()
@@ -637,6 +658,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early). # display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
job.display_name = meta.title[:255] if meta.title else None job.display_name = meta.title[:255] if meta.title else None
db.commit() db.commit()
_notify_download_done(db, job, meta.title)
log.info("job %s done: %s", job_id, rel) log.info("job %s done: %s", job_id, rel)
except (_Aborted, editmod.EditAborted): except (_Aborted, editmod.EditAborted):
@@ -769,6 +791,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
job.progress = 100 job.progress = 100
job.phase = None job.phase = None
db.commit() db.commit()
_notify_download_done(db, job, asset.title if asset else None)
log.info("edit job %s done: %s", job_id, rel) log.info("edit job %s done: %s", job_id, rel)
except editmod.EditAborted: except editmod.EditAborted:
+21 -1
View File
@@ -8,6 +8,7 @@ import {
Bell, Bell,
Check, Check,
CheckCheck, CheckCheck,
Download,
ExternalLink, ExternalLink,
Eye, Eye,
RotateCcw, RotateCcw,
@@ -18,6 +19,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type AppNotification } from "../lib/api"; import { api, type AppNotification } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import { import {
@@ -114,7 +116,7 @@ export default function NotificationsPanel() {
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible). // Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId); resolveVideo(meta.videoId);
}) })
.catch(() => {}); .catch((e) => notifyActionError(e, t("inbox.revertFailed")));
} }
return ( return (
@@ -180,6 +182,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)} onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={() => setPage("scheduler")} onOpenScheduler={() => setPage("scheduler")}
onOpenDownloads={() => setPage("downloads")}
/> />
))} ))}
</div> </div>
@@ -222,12 +225,14 @@ function NotificationRow({
onRead, onRead,
onDismiss, onDismiss,
onOpenScheduler, onOpenScheduler,
onOpenDownloads,
}: { }: {
n: AppNotification; n: AppNotification;
t: TFunction; t: TFunction;
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void; onOpenScheduler: () => void;
onOpenDownloads: () => void;
}) { }) {
// The maintenance producer ships the affected videos in `data.videos`; list a few titles. // The maintenance producer ships the affected videos in `data.videos`; list a few titles.
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos) const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
@@ -237,6 +242,7 @@ function NotificationRow({
// payload; the server-stored title/body are an English fallback for any unknown type. // payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number"; const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string"; const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
const isDownloadDone = n.type === "download_done";
let title = n.title; let title = n.title;
let body = n.body; let body = n.body;
if (isMaintenance) { if (isMaintenance) {
@@ -252,6 +258,11 @@ function NotificationRow({
job, job,
}); });
body = d.summary || n.body; body = d.summary || n.body;
} else if (isDownloadDone) {
// "Download ready", with the video's title as the body (the producer ships it in data.title).
const d = n.data as { title?: string | null };
title = t("inbox.downloadDone.title");
body = d.title || n.body;
} }
return ( return (
<div <div
@@ -289,6 +300,15 @@ function NotificationRow({
{t("inbox.openScheduler")} {t("inbox.openScheduler")}
</button> </button>
)} )}
{isDownloadDone && (
<button
onClick={onOpenDownloads}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
>
<Download className="w-3.5 h-3.5" />
{t("inbox.openDownloads")}
</button>
)}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{!n.read && ( {!n.read && (
+22 -3
View File
@@ -44,6 +44,7 @@ import {
type PlexUnifiedResult, type PlexUnifiedResult,
} from "../lib/api"; } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import { import {
@@ -223,7 +224,13 @@ export default function PlexBrowse() {
async function toggleWatched(card: PlexCard) { async function toggleWatched(card: PlexCard) {
const next = card.status === "watched" ? "new" : "watched"; const next = card.status === "watched" ? "new" : "watched";
optimisticStatus(card.id, next); 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() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // 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); setBusy(true);
try { try {
await api.plexShowState(showId, w); await api.plexShowState(showId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -1019,7 +1028,11 @@ function PlexShowView({
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
async function markSeason(seasonRk: string, w: boolean) { 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.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
@@ -1253,6 +1266,8 @@ function PlexSeasonView({
setBusy(true); setBusy(true);
try { try {
await api.plexSeasonState(seasonId, w); await api.plexSeasonState(seasonId, w);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -1260,7 +1275,11 @@ function PlexSeasonView({
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
async function markEpisode(ep: PlexCard) { 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.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
+6 -1
View File
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { import {
DetailCustomizeMenu, DetailCustomizeMenu,
Filterable, Filterable,
@@ -77,7 +78,11 @@ export default function PlexInfo({
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => { const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {}); try {
await api.plexSetState(detail.id, status);
} catch (e) {
notifyActionError(e, t("plex.watchedFailed"));
}
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) }); qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() }); qc.invalidateQueries({ queryKey: qk.plexShow() });
+29 -5
View File
@@ -21,6 +21,7 @@ import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState"; import { filtersToParams, shareUrl } from "../lib/urlState";
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 { accountKey, LS, writeJSON } from "../lib/storage"; import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@@ -78,21 +79,34 @@ export default function SavedViewsWidget({
if (!name) return; if (!name) return;
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
const { seed: _seed, ...clean } = filters; const { seed: _seed, ...clean } = filters;
await api.createView(name, clean);
setNaming(false); setNaming(false);
setDraftName(""); setDraftName("");
try {
await api.createView(name, clean);
} catch (e) {
notifyActionError(e, t("views.actionFailed"));
}
refresh(); refresh();
} }
async function rename(id: number, name: string) { async function rename(id: number, name: string) {
const next = name.trim(); const next = name.trim();
if (next) await api.updateView(id, { name: next });
setRenameId(null); setRenameId(null);
if (!next) return;
try {
await api.updateView(id, { name: next });
} catch (e) {
notifyActionError(e, t("views.actionFailed"));
}
refresh(); refresh();
} }
async function toggleDefault(v: SavedView) { async function toggleDefault(v: SavedView) {
await api.updateView(v.id, { is_default: !v.is_default }); try {
await api.updateView(v.id, { is_default: !v.is_default });
} catch (e) {
notifyActionError(e, t("views.actionFailed"));
}
refresh(); refresh();
} }
@@ -103,7 +117,11 @@ export default function SavedViewsWidget({
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
await api.deleteView(v.id); try {
await api.deleteView(v.id);
} catch (e) {
notifyActionError(e, t("views.actionFailed"));
}
refresh(); refresh();
} }
@@ -128,7 +146,13 @@ export default function SavedViewsWidget({
qk.savedViews(), qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!), next.map((id) => views.find((v) => v.id === id)!),
); );
await api.reorderViews(next); // On failure the refetch below reverts the optimistic order to the server's truth; without the
// toast the drop would silently snap back with no explanation.
try {
await api.reorderViews(next);
} catch (e) {
notifyActionError(e, t("views.reorderFailed"));
}
refresh(); refresh();
} }
+5
View File
@@ -12,6 +12,11 @@
"sectionActivity": "Activity", "sectionActivity": "Activity",
"andMore": "and {{count}} more", "andMore": "and {{count}} more",
"openScheduler": "Open Scheduler", "openScheduler": "Open Scheduler",
"openDownloads": "Open Downloads",
"revertFailed": "Couldn't undo that — please try again.",
"downloadDone": {
"title": "Download ready"
},
"maintenance": { "maintenance": {
"title": "Videos removed", "title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",
+1
View File
@@ -74,6 +74,7 @@
"openShow": "Open show", "openShow": "Open show",
"markWatched": "Mark watched", "markWatched": "Mark watched",
"markUnwatched": "Mark unwatched", "markUnwatched": "Mark unwatched",
"watchedFailed": "Couldn't update the watched state — please try again.",
"seasons": "{{count}} seasons", "seasons": "{{count}} seasons",
"unified": { "unified": {
"titles": "Titles", "titles": "Titles",
+3 -1
View File
@@ -12,5 +12,7 @@
"rename": "Rename", "rename": "Rename",
"delete": "Delete", "delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?", "deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder" "reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again.",
"reorderFailed": "Couldn't save the new order — please try again."
} }
+5
View File
@@ -12,6 +12,11 @@
"sectionActivity": "Tevékenység", "sectionActivity": "Tevékenység",
"andMore": "és még {{count}}", "andMore": "és még {{count}}",
"openScheduler": "Ütemező megnyitása", "openScheduler": "Ütemező megnyitása",
"openDownloads": "Letöltések megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra.",
"downloadDone": {
"title": "Letöltés kész"
},
"maintenance": { "maintenance": {
"title": "Videók eltávolítva", "title": "Videók eltávolítva",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.", "body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",
+1
View File
@@ -74,6 +74,7 @@
"openShow": "Sorozat megnyitása", "openShow": "Sorozat megnyitása",
"markWatched": "Megnézettnek jelöl", "markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl", "markUnwatched": "Nem-nézettnek jelöl",
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
"seasons": "{{count}} évad", "seasons": "{{count}} évad",
"unified": { "unified": {
"titles": "Címek", "titles": "Címek",
+3 -1
View File
@@ -12,5 +12,7 @@
"rename": "Átnevezés", "rename": "Átnevezés",
"delete": "Törlés", "delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?", "deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez" "reorder": "Húzd az átrendezéshez",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra."
} }
+14
View File
@@ -0,0 +1,14 @@
import { HttpError } from "./api";
import { notify } from "./notifications";
/**
* Surface a failed write action as an error toast instead of the click silently doing nothing.
* Prefers the server's own reason (`HttpError.detail` — e.g. "Not enough quota left today", a
* 404/429 message) over the caller's already-translated generic fallback. Domain-agnostic: used by
* 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 {
const detail = err instanceof HttpError ? err.detail : null;
notify({ level: "error", message: detail || fallbackMessage });
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { HttpError } from "./api"; import { HttpError } from "./api";
import { notify } from "./notifications"; import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n"; import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't // A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
@@ -25,5 +26,5 @@ export function notifyYouTubeActionError(
} }
// Any other error: prefer the server's own reason (e.g. "Not enough quota left today") over the // Any other error: prefer the server's own reason (e.g. "Not enough quota left today") over the
// caller's generic fallback — the useful detail was being discarded (U-3.2.8). // caller's generic fallback — the useful detail was being discarded (U-3.2.8).
notify({ level: "error", message: httpErr?.detail || fallbackMessage }); notifyActionError(err, fallbackMessage);
} }