From e2fe6fa286cfa3427163487790f79d8c0b9da21d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 05:05:50 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(ux):=20R11=20feedback=20symmetry=20?= =?UTF-8?q?=E2=80=94=20Plex/download/rollback/deny=20notices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/email.py | 13 +++++++ backend/app/routes/admin.py | 7 +++- backend/app/worker.py | 23 +++++++++++++ .../src/components/NotificationsPanel.tsx | 22 +++++++++++- frontend/src/components/PlexBrowse.tsx | 25 ++++++++++++-- frontend/src/components/PlexInfo.tsx | 7 +++- frontend/src/components/SavedViewsWidget.tsx | 34 ++++++++++++++++--- frontend/src/i18n/locales/en/inbox.json | 5 +++ frontend/src/i18n/locales/en/plex.json | 1 + frontend/src/i18n/locales/en/views.json | 4 ++- frontend/src/i18n/locales/hu/inbox.json | 5 +++ frontend/src/i18n/locales/hu/plex.json | 1 + frontend/src/i18n/locales/hu/views.json | 4 ++- frontend/src/lib/actionErrors.ts | 14 ++++++++ frontend/src/lib/youtubeErrors.ts | 3 +- 15 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/actionErrors.ts diff --git a/backend/app/email.py b/backend/app/email.py index 4f5c108a..34006967 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -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) +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: line = ( "You've been granted admin access on Siftlode — you can now manage settings, the " diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 576bbc05..12425b87 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -105,10 +105,15 @@ def approve_invite( @router.post("/invites/{invite_id}/deny") def deny_invite( invite_id: int, + background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> 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): diff --git a/backend/app/worker.py b/backend/app/worker.py index 8257923a..9ae998b8 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -34,10 +34,31 @@ from app.downloads import edit as editmod from app.downloads import errors as dlerrors from app.downloads import formats, quota, service, storage from app.models import DownloadJob, MediaAsset +from app.notifications import create_notification from app.titles import normalize_title 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() @@ -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). job.display_name = meta.title[:255] if meta.title else None db.commit() + _notify_download_done(db, job, meta.title) log.info("job %s done: %s", job_id, rel) except (_Aborted, editmod.EditAborted): @@ -769,6 +791,7 @@ def _process_edit(job_id: int, asset_id: int) -> None: job.progress = 100 job.phase = None db.commit() + _notify_download_done(db, job, asset.title if asset else None) log.info("edit job %s done: %s", job_id, rel) except editmod.EditAborted: diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index aad9cd7d..e0b15c34 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -8,6 +8,7 @@ import { Bell, Check, CheckCheck, + Download, ExternalLink, Eye, RotateCcw, @@ -18,6 +19,7 @@ import { X, } from "lucide-react"; import { api, type AppNotification } from "../lib/api"; +import { notifyActionError } from "../lib/actionErrors"; import { useLiveQuery } from "../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { @@ -114,7 +116,7 @@ export default function NotificationsPanel() { // Quietly resolve the now-stale notice (no confirmation toast — the change is visible). resolveVideo(meta.videoId); }) - .catch(() => {}); + .catch((e) => notifyActionError(e, t("inbox.revertFailed"))); } return ( @@ -180,6 +182,7 @@ export default function NotificationsPanel() { onRead={() => readMut.mutate(n.id)} onDismiss={() => dismissMut.mutate(n.id)} onOpenScheduler={() => setPage("scheduler")} + onOpenDownloads={() => setPage("downloads")} /> ))} @@ -222,12 +225,14 @@ function NotificationRow({ onRead, onDismiss, onOpenScheduler, + onOpenDownloads, }: { n: AppNotification; t: TFunction; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; + onOpenDownloads: () => void; }) { // 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) @@ -237,6 +242,7 @@ function NotificationRow({ // 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 isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string"; + const isDownloadDone = n.type === "download_done"; let title = n.title; let body = n.body; if (isMaintenance) { @@ -252,6 +258,11 @@ function NotificationRow({ job, }); 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 (
)} + {isDownloadDone && ( + + )}
{!n.read && ( diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4a871832..6aedf49d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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() }); } diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 26dd8e54..9440fdf4 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; +import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, @@ -77,7 +78,11 @@ export default function PlexInfo({ const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "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.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexShow() }); diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 03fc6880..b6d4cccc 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -21,6 +21,7 @@ import { CSS } from "@dnd-kit/utilities"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { filtersToParams, shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; +import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; import { accountKey, LS, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; @@ -78,21 +79,34 @@ export default function SavedViewsWidget({ if (!name) return; // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. const { seed: _seed, ...clean } = filters; - await api.createView(name, clean); setNaming(false); setDraftName(""); + try { + await api.createView(name, clean); + } catch (e) { + notifyActionError(e, t("views.actionFailed")); + } refresh(); } async function rename(id: number, name: string) { const next = name.trim(); - if (next) await api.updateView(id, { name: next }); setRenameId(null); + if (!next) return; + try { + await api.updateView(id, { name: next }); + } catch (e) { + notifyActionError(e, t("views.actionFailed")); + } refresh(); } 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(); } @@ -103,7 +117,11 @@ export default function SavedViewsWidget({ danger: true, }); if (!ok) return; - await api.deleteView(v.id); + try { + await api.deleteView(v.id); + } catch (e) { + notifyActionError(e, t("views.actionFailed")); + } refresh(); } @@ -128,7 +146,13 @@ export default function SavedViewsWidget({ qk.savedViews(), 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(); } diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index 975fe638..e44f6fd0 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -12,6 +12,11 @@ "sectionActivity": "Activity", "andMore": "and {{count}} more", "openScheduler": "Open Scheduler", + "openDownloads": "Open Downloads", + "revertFailed": "Couldn't undo that — please try again.", + "downloadDone": { + "title": "Download ready" + }, "maintenance": { "title": "Videos removed", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index dfa24256..301c0998 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -74,6 +74,7 @@ "openShow": "Open show", "markWatched": "Mark watched", "markUnwatched": "Mark unwatched", + "watchedFailed": "Couldn't update the watched state — please try again.", "seasons": "{{count}} seasons", "unified": { "titles": "Titles", diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json index 487a87ab..a2e41742 100644 --- a/frontend/src/i18n/locales/en/views.json +++ b/frontend/src/i18n/locales/en/views.json @@ -12,5 +12,7 @@ "rename": "Rename", "delete": "Delete", "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." } diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 44222540..99b8e747 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -12,6 +12,11 @@ "sectionActivity": "Tevékenység", "andMore": "és még {{count}}", "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": { "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.", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index c64286df..78c40a67 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -74,6 +74,7 @@ "openShow": "Sorozat megnyitása", "markWatched": "Megné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", "unified": { "titles": "Címek", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json index 652ba09a..d95f1c05 100644 --- a/frontend/src/i18n/locales/hu/views.json +++ b/frontend/src/i18n/locales/hu/views.json @@ -12,5 +12,7 @@ "rename": "Átnevezés", "delete": "Törlés", "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." } diff --git a/frontend/src/lib/actionErrors.ts b/frontend/src/lib/actionErrors.ts new file mode 100644 index 00000000..3b94c5bb --- /dev/null +++ b/frontend/src/lib/actionErrors.ts @@ -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 }); +} diff --git a/frontend/src/lib/youtubeErrors.ts b/frontend/src/lib/youtubeErrors.ts index 22d07af5..25c3773c 100644 --- a/frontend/src/lib/youtubeErrors.ts +++ b/frontend/src/lib/youtubeErrors.ts @@ -1,5 +1,6 @@ import { HttpError } from "./api"; import { notify } from "./notifications"; +import { notifyActionError } from "./actionErrors"; import i18n from "../i18n"; // 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 // caller's generic fallback — the useful detail was being discarded (U-3.2.8). - notify({ level: "error", message: httpErr?.detail || fallbackMessage }); + notifyActionError(err, fallbackMessage); } From 359bac149b66ce613e941f025f5eb4c7f5ee131f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 20:25:18 +0200 Subject: [PATCH 2/3] refactor: address /code-review on R11 feedback symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin._decide: add an idempotency guard (409 unless status was 'pending') so a repeat/stale approve OR deny can't re-run its side effects — now that deny emails, this stops duplicate denial mails and a rejection mail to an already-approved user. - SavedViewsWidget: extract runViewMutation(fn, failMsg) (returns success) — dedups the five try/catch+refresh blocks AND lets create/rename close their input only on success, so a failed create keeps the user's typed name. - worker._notify_download_done: take user_id/title captured BEFORE the caller's commit (no post-commit refresh SELECT); add a so the inbox labels an edit job 'Edit ready' instead of 'Download ready' (new inbox.editDone i18n, HU+EN). Gate green (ruff/tsc/eslint-0/prettier/knip/vitest 79/pytest 193); smoke-tested the saved-views + notifications surfaces render clean on a fresh dev server. --- backend/app/routes/admin.py | 6 ++ backend/app/worker.py | 33 +++++---- .../src/components/NotificationsPanel.tsx | 7 +- frontend/src/components/SavedViewsWidget.tsx | 69 ++++++++++--------- frontend/src/i18n/locales/en/inbox.json | 3 + frontend/src/i18n/locales/hu/inbox.json | 3 + 6 files changed, 72 insertions(+), 49 deletions(-) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 12425b87..3a1f5ad9 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -74,6 +74,12 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: inv = db.get(Invite, invite_id) if inv is None: raise HTTPException(status_code=404, detail="No such invite") + # Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale + # approve or deny re-runs its side effects — re-activating a user, or (now that deny emails) + # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and + # mailing a rejection to an already-active account. + if inv.status != "pending": + raise HTTPException(status_code=409, detail="Invite already decided") inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email diff --git a/backend/app/worker.py b/backend/app/worker.py index 9ae998b8..846f7d7b 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -40,24 +40,28 @@ from app.titles import normalize_title 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: +def _notify_download_done( + db, user_id: int | None, title: str | None, job_id, *, kind: str +) -> None: + """Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish + silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A + notification failure must never mask the completed job, so it is isolated + rolled back (mirrors + scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the + English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the + caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row.""" + if user_id is None: return try: create_notification( db, - user_id=job.user_id, + user_id=user_id, type="download_done", - title=f"Download ready: {title}" if title else "Download ready", - data={"title": title}, + title=f"Ready to watch: {title}" if title else "Ready to watch", + data={"title": title, "kind": kind}, ) except Exception: db.rollback() - log.exception("failed to write download-done notification for job %s", job.id) + log.exception("failed to write download-done notification for job %s", job_id) _stop = threading.Event() @@ -657,8 +661,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe if not job.display_name: # 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 + # Capture the owner before commit expires the row (avoids a refresh SELECT just to notify). + owner_id = job.user_id if job else None db.commit() - _notify_download_done(db, job, meta.title) + _notify_download_done(db, owner_id, meta.title, job_id, kind="download") log.info("job %s done: %s", job_id, rel) except (_Aborted, editmod.EditAborted): @@ -790,8 +796,11 @@ def _process_edit(job_id: int, asset_id: int) -> None: job.status = "done" job.progress = 100 job.phase = None + # Capture owner + title before commit expires the rows (avoids a refresh SELECT). + owner_id = job.user_id if job else None + dl_title = asset.title if asset else None db.commit() - _notify_download_done(db, job, asset.title if asset else None) + _notify_download_done(db, owner_id, dl_title, job_id, kind="edit") log.info("edit job %s done: %s", job_id, rel) except editmod.EditAborted: diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index e0b15c34..c0b8eecf 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -259,9 +259,10 @@ function NotificationRow({ }); 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"); + // "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body + // (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled. + const d = n.data as { title?: string | null; kind?: string }; + title = t(d.kind === "edit" ? "inbox.editDone.title" : "inbox.downloadDone.title"); body = d.title || n.body; } return ( diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index b6d4cccc..44d6b6dd 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -72,6 +72,22 @@ export default function SavedViewsWidget({ }, [viewsQuery.data]); const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() }); + + // Run a saved-view mutation with uniform feedback: on failure surface the server's reason instead + // of a silent swallow, and always refetch afterwards (which also reverts an optimistic change to + // the server's truth). Returns whether it succeeded, so callers can gate success-only side effects + // (e.g. closing an editor) and keep the input open — with the user's text — on failure. + async function runViewMutation(fn: () => Promise, failMsg: string): Promise { + try { + await fn(); + return true; + } catch (e) { + notifyActionError(e, failMsg); + return false; + } finally { + refresh(); + } + } const currentKey = keyOf(filters); async function save() { @@ -79,35 +95,30 @@ export default function SavedViewsWidget({ if (!name) return; // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. const { seed: _seed, ...clean } = filters; - setNaming(false); - setDraftName(""); - try { - await api.createView(name, clean); - } catch (e) { - notifyActionError(e, t("views.actionFailed")); + // Close the naming input only on success, so a failed create keeps the user's typed name. + if (await runViewMutation(() => api.createView(name, clean), t("views.actionFailed"))) { + setNaming(false); + setDraftName(""); } - refresh(); } async function rename(id: number, name: string) { const next = name.trim(); - setRenameId(null); - if (!next) return; - try { - await api.updateView(id, { name: next }); - } catch (e) { - notifyActionError(e, t("views.actionFailed")); + if (!next) { + setRenameId(null); + return; + } + // Close the rename editor only on success, so a failed rename stays open for a retry. + if (await runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"))) { + setRenameId(null); } - refresh(); } async function toggleDefault(v: SavedView) { - try { - await api.updateView(v.id, { is_default: !v.is_default }); - } catch (e) { - notifyActionError(e, t("views.actionFailed")); - } - refresh(); + await runViewMutation( + () => api.updateView(v.id, { is_default: !v.is_default }), + t("views.actionFailed"), + ); } async function remove(v: SavedView) { @@ -117,12 +128,7 @@ export default function SavedViewsWidget({ danger: true, }); if (!ok) return; - try { - await api.deleteView(v.id); - } catch (e) { - notifyActionError(e, t("views.actionFailed")); - } - refresh(); + await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); } async function share(v: SavedView) { @@ -146,14 +152,9 @@ export default function SavedViewsWidget({ qk.savedViews(), next.map((id) => views.find((v) => v.id === id)!), ); - // 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(); + // On failure runViewMutation's refetch reverts the optimistic order to the server's truth; + // without the toast the drop would silently snap back with no explanation. + await runViewMutation(() => api.reorderViews(next), t("views.reorderFailed")); } return ( diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index e44f6fd0..e2138f3c 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -17,6 +17,9 @@ "downloadDone": { "title": "Download ready" }, + "editDone": { + "title": "Edit ready" + }, "maintenance": { "title": "Videos removed", "body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 99b8e747..d9e817a5 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -17,6 +17,9 @@ "downloadDone": { "title": "Letöltés kész" }, + "editDone": { + "title": "Vágás kész" + }, "maintenance": { "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.", From c4fde626a4ec4871d0a211915d90cbff99e6e3bd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 20:40:46 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=200.62.0=20=E2=80=94=20R11?= =?UTF-8?q?=20feedback=20symmetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERSION bump + user-facing release notes for R11 (Plex error toasts, download/edit ready notifications, saved-view rollbacks + retries, access-request denial emails). --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6bcc0367..4d74f323 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.61.1 +0.62.0 diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 3f45e06e..ea7c77c7 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,20 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.62.0", + date: "2026-08-11", + summary: + "Actions that used to fail or finish silently now tell you what happened — consistent feedback across Plex, downloads, saved views and access requests.", + features: [ + "Downloads (and clip/crop edits) now drop a notification in your inbox when they're ready, with a link straight to the Downloads page.", + "Denied access requests now email the requester, the same way approvals already did — no more silent limbo.", + ], + fixes: [ + "Plex mark-watched / mark-unwatched now shows an error if it fails, instead of the click appearing to do nothing.", + "Saved views: a failed rename, reorder, delete or create now shows an error and recovers cleanly (a failed drag-reorder snaps back to the real order), instead of silently swallowing the failure — and a failed create keeps your typed name so you can retry.", + ], + }, { version: "0.61.1", date: "2026-08-11",