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/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..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
@@ -105,10 +111,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..846f7d7b 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -34,10 +34,35 @@ 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, 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=user_id,
+ type="download_done",
+ 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)
+
_stop = threading.Event()
@@ -636,7 +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, owner_id, meta.title, job_id, kind="download")
log.info("job %s done: %s", job_id, rel)
except (_Aborted, editmod.EditAborted):
@@ -768,7 +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, 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 aad9cd7d..c0b8eecf 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,12 @@ function NotificationRow({
job,
});
body = d.summary || n.body;
+ } else if (isDownloadDone) {
+ // "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 (
)}
+ {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..44d6b6dd 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";
@@ -71,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() {
@@ -78,22 +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;
- await api.createView(name, clean);
- setNaming(false);
- setDraftName("");
- refresh();
+ // 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("");
+ }
}
async function rename(id: number, name: string) {
const next = name.trim();
- if (next) await api.updateView(id, { name: next });
- setRenameId(null);
- refresh();
+ 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);
+ }
}
async function toggleDefault(v: SavedView) {
- await api.updateView(v.id, { is_default: !v.is_default });
- refresh();
+ await runViewMutation(
+ () => api.updateView(v.id, { is_default: !v.is_default }),
+ t("views.actionFailed"),
+ );
}
async function remove(v: SavedView) {
@@ -103,8 +128,7 @@ export default function SavedViewsWidget({
danger: true,
});
if (!ok) return;
- await api.deleteView(v.id);
- refresh();
+ await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
}
async function share(v: SavedView) {
@@ -128,8 +152,9 @@ export default function SavedViewsWidget({
qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!),
);
- await api.reorderViews(next);
- 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 975fe638..e2138f3c 100644
--- a/frontend/src/i18n/locales/en/inbox.json
+++ b/frontend/src/i18n/locales/en/inbox.json
@@ -12,6 +12,14 @@
"sectionActivity": "Activity",
"andMore": "and {{count}} more",
"openScheduler": "Open Scheduler",
+ "openDownloads": "Open Downloads",
+ "revertFailed": "Couldn't undo that — please try again.",
+ "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/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..d9e817a5 100644
--- a/frontend/src/i18n/locales/hu/inbox.json
+++ b/frontend/src/i18n/locales/hu/inbox.json
@@ -12,6 +12,14 @@
"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"
+ },
+ "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.",
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/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",
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);
}