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:
@@ -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 "
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -289,6 +300,15 @@ function NotificationRow({
|
||||
{t("inbox.openScheduler")}
|
||||
</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 className="flex items-center gap-1 shrink-0">
|
||||
{!n.read && (
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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) {
|
||||
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;
|
||||
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)!),
|
||||
);
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user