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); }