From dd8371830415963ebb9c61ea4f37f206bcf8e9aa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 04:37:08 +0200 Subject: [PATCH 1/2] feat(scheduler): admin-tunable maintenance re-validation batch size Expose the maintenance job's rolling re-validation batch (videos re-checked per run) as an admin control on the Scheduler dashboard, stored in app_state (migration 0018; NULL = the env/config default). The job reads the effective value each run via a state helper, clamped to a sane range. Trilingual. --- .../0018_maintenance_batch_setting.py | 30 +++ backend/app/models.py | 3 + backend/app/routes/scheduler.py | 29 +++ backend/app/state.py | 22 ++ backend/app/sync/maintenance.py | 3 +- .../src/components/NotificationCenter.tsx | 231 ------------------ frontend/src/components/Scheduler.tsx | 68 ++++++ frontend/src/i18n/locales/de/scheduler.json | 6 + frontend/src/i18n/locales/en/scheduler.json | 6 + frontend/src/i18n/locales/hu/scheduler.json | 6 + frontend/src/lib/api.ts | 11 + 11 files changed, 183 insertions(+), 232 deletions(-) create mode 100644 backend/alembic/versions/0018_maintenance_batch_setting.py delete mode 100644 frontend/src/components/NotificationCenter.tsx diff --git a/backend/alembic/versions/0018_maintenance_batch_setting.py b/backend/alembic/versions/0018_maintenance_batch_setting.py new file mode 100644 index 0000000..07b9f9e --- /dev/null +++ b/backend/alembic/versions/0018_maintenance_batch_setting.py @@ -0,0 +1,30 @@ +"""admin override for the maintenance re-validation batch size + +Revision ID: 0018_maintenance_batch_setting +Revises: 0017_notifications +Create Date: 2026-06-18 + +Adds app_state.maintenance_revalidate_batch: an admin-tunable override (from the Scheduler +dashboard) for how many videos the maintenance job re-validates per run. NULL = use the +env/config default. Purely additive. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0018_maintenance_batch_setting" +down_revision: Union[str, None] = "0017_notifications" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "app_state", + sa.Column("maintenance_revalidate_batch", sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("app_state", "maintenance_revalidate_batch") diff --git a/backend/app/models.py b/backend/app/models.py index dfe3e13..fca20f3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -339,6 +339,9 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + # Admin override for the maintenance job's rolling re-validation batch (videos re-checked + # per run). NULL = use the config/env default (settings.maintenance_revalidate_batch). + maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer) class SchedulerSetting(Base): diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index 82a4c82..66e4f8f 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -71,6 +71,29 @@ def run_all_now(user: User = Depends(admin_user)) -> dict: return {"started": trigger_all(actor_id=user.id)} +@router.patch("/maintenance") +def set_maintenance_settings( + payload: dict, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per + run). Persisted to app_state; the job reads it each run.""" + try: + value = int(payload.get("revalidate_batch")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="revalidate_batch must be a number") + if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX): + raise HTTPException( + status_code=400, + detail=( + f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and " + f"{state.MAINTENANCE_BATCH_MAX}" + ), + ) + return {"revalidate_batch": state.set_maintenance_batch(db, value)} + + @router.get("") def get_scheduler( _: User = Depends(admin_user), db: Session = Depends(get_db) @@ -138,4 +161,10 @@ def get_scheduler( "paused": state.is_sync_paused(db), "queue": queue, "quota": quota_info, + "maintenance": { + "revalidate_batch": state.get_maintenance_batch(db), + "revalidate_batch_default": settings.maintenance_revalidate_batch, + "min": state.MAINTENANCE_BATCH_MIN, + "max": state.MAINTENANCE_BATCH_MAX, + }, } diff --git a/backend/app/state.py b/backend/app/state.py index ad85e19..34f56fa 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -3,10 +3,15 @@ import logging from sqlalchemy.orm import Session +from app.config import settings from app.models import AppState log = logging.getLogger("subfeed.state") +# Bounds for the admin-set maintenance re-validation batch (videos re-checked per run). +MAINTENANCE_BATCH_MIN = 100 +MAINTENANCE_BATCH_MAX = 100_000 + def _row(db: Session) -> AppState: row = db.get(AppState, 1) @@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None: db.add(row) db.commit() log.info("Background sync %s", "paused" if paused else "resumed") + + +def get_maintenance_batch(db: Session) -> int: + """Effective maintenance re-validation batch: the admin override if set, else the + env/config default.""" + return _row(db).maintenance_revalidate_batch or settings.maintenance_revalidate_batch + + +def set_maintenance_batch(db: Session, value: int) -> int: + """Clamp and persist the maintenance batch override. Returns the applied value.""" + value = max(MAINTENANCE_BATCH_MIN, min(MAINTENANCE_BATCH_MAX, int(value))) + row = _row(db) + row.maintenance_revalidate_batch = value + db.add(row) + db.commit() + log.info("Maintenance re-validation batch set to %s", value) + return value diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index 7e308df..47e3569 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -29,6 +29,7 @@ from app import progress, quota from app.config import settings from app.models import Playlist, PlaylistItem, Video, VideoState from app.notifications import create_notification +from app.state import get_maintenance_batch from app.sync.runner import get_service_user from app.sync.videos import apply_video_details, parse_dt from app.youtube.client import YouTubeClient @@ -189,7 +190,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict: def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict: """Phase 2: re-check the least-recently-checked currently-available videos and flag any that have become unplayable (hidden immediately; deleted later if they don't recover).""" - batch = settings.maintenance_revalidate_batch + batch = get_maintenance_batch(db) # admin override, else config default flagged_new = 0 checked = 0 now = _now() diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx deleted file mode 100644 index 146bf14..0000000 --- a/frontend/src/components/NotificationCenter.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { useQueryClient } from "@tanstack/react-query"; -import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react"; -import { api, type FeedFilters } from "../lib/api"; -import { - clearAll, - dismiss, - getNotifications, - getUnreadCount, - markAllRead, - notify, - subscribe, - type VideoHiddenMeta, - type VideoWatchedMeta, -} from "../lib/notifications"; -import { LEVEL_STYLE } from "./Toaster"; - -function relTime(ts: number, t: TFunction): string { - const s = Math.round((Date.now() - ts) / 1000); - if (s < 60) return t("notifications.time.justNow"); - const m = Math.round(s / 60); - if (m < 60) return t("notifications.time.minutes", { count: m }); - const h = Math.round(m / 60); - if (h < 24) return t("notifications.time.hours", { count: h }); - const d = Math.round(h / 24); - return t("notifications.time.days", { count: d }); -} - -export default function NotificationCenter({ - filters, - setFilters, - variant = "header", -}: { - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; - variant?: "header" | "rail"; -}) { - const { t } = useTranslation(); - const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications); - const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); - const [open, setOpen] = useState(false); - const wrap = useRef(null); - const btnRef = useRef(null); - const panelRef = useRef(null); - // Rail variant anchors the panel right + above the button (fixed, viewport coords) and - // portals it to so it escapes the nav's backdrop-filter. - const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); - const qc = useQueryClient(); - - // Close when clicking anywhere outside the button + panel. - useEffect(() => { - if (!open) return; - function onDown(e: MouseEvent) { - const target = e.target as Node; - if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return; - if (variant === "header" && wrap.current?.contains(target)) return; - setOpen(false); - } - document.addEventListener("mousedown", onDown); - return () => document.removeEventListener("mousedown", onDown); - }, [open, variant]); - - function toggle() { - if (!open && variant === "rail") { - const r = btnRef.current?.getBoundingClientRect(); - if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); - } - setOpen((o) => !o); - } - - // Opening the center marks everything read. - useEffect(() => { - if (open) markAllRead(); - }, [open, notifications.length]); - - function locateHidden(meta: VideoHiddenMeta) { - setFilters({ - ...filters, - show: "hidden", - channelId: meta.channelId || undefined, - channelName: meta.channelName || undefined, - }); - setOpen(false); - } - - function revertState( - meta: VideoHiddenMeta | VideoWatchedMeta, - messageKey: "notifications.unhidden" | "notifications.unwatched" - ) { - api - .setState(meta.videoId, "new") - .then(() => { - qc.invalidateQueries({ queryKey: ["feed"] }); - qc.invalidateQueries({ queryKey: ["feed-count"] }); - notify({ level: "success", message: t(messageKey, { title: meta.title }) }); - }) - .catch(() => {}); - } - - function renderPanel(el: JSX.Element) { - if (variant !== "rail") return el; - return createPortal( -
- {el} -
, - document.body - ); - } - - return ( -
- - - {open && - renderPanel( -
-
-
{t("notifications.title")}
- {notifications.length > 0 && ( - - )} -
- -
- {notifications.length === 0 ? ( -
{t("notifications.empty")}
- ) : ( - notifications.map((n) => { - const { icon: Icon, color } = LEVEL_STYLE[n.level]; - const needsAction = n.requiresInteraction && !n.dismissed; - const hidden = n.meta?.kind === "video-hidden" ? n.meta : null; - const watched = n.meta?.kind === "video-watched" ? n.meta : null; - return ( -
- -
- {n.title &&
{n.title}
} -
{n.message}
-
- {relTime(n.ts, t)} - {needsAction && ( - - {t("notifications.actionNeeded")} - - )} -
- - {hidden ? ( -
- - -
- ) : watched ? ( -
- -
- ) : ( - n.action && - !n.dismissed && ( - - ) - )} -
-
- ); - }) - )} -
-
- )} -
- ); -} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 697722b..549916e 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -212,6 +212,60 @@ function JobRow({ ); } +function MaintenanceSettings({ + m, + onSave, + saving, +}: { + m: SchedulerStatus["maintenance"]; + onSave: (batch: number) => void; + saving: boolean; +}) { + const { t } = useTranslation(); + const [val, setVal] = useState(String(m.revalidate_batch)); + useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]); + + function save() { + const n = parseInt(val, 10); + if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n); + } + const dirty = String(m.revalidate_batch) !== val; + + return ( +
+
+ + + + {t("scheduler.maintenance.batchLabel")} + + + + setVal(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && save()} + className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent" + /> + +
+
+ {t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })} +
+
+ ); +} + function Stat({ icon: Icon, label, @@ -285,6 +339,10 @@ export default function Scheduler() { qc.invalidateQueries({ queryKey: ["scheduler"] }); }, }); + const batchMut = useMutation({ + mutationFn: (v: number) => api.updateMaintenanceBatch(v), + onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), + }); if (q.isLoading && !data) return
{t("scheduler.loading")}
; @@ -432,6 +490,16 @@ export default function Scheduler() {
{t("scheduler.quotaNote")}
+ + {/* Maintenance settings */} +
+
{t("scheduler.maintenance.title")}
+ batchMut.mutate(batch)} + saving={batchMut.isPending} + /> +
); } diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index 28fa613..8c30032 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Ganze Historie nachladen", "probe": "Shorts klassifizieren" }, + "maintenance": { + "title": "Wartung", + "batchLabel": "Pro Lauf erneut geprüfte Videos", + "batchHint": "Wie viele am längsten nicht geprüfte Videos der Wartungs-Job pro Lauf erneut validiert. Höher = schnellerer Gesamtzyklus, aber mehr API-Kontingent (1 Einheit je 50 Videos).", + "batchNote": "Standard: {{default}}. ~233k Videos ÷ dieser Wert = Tage für einen vollen Prüfzyklus." + }, "dot": { "running": "läuft gerade", "ok": "letzter Lauf OK", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 6c45ed5..b33bcf5 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Backfilling full history", "probe": "Classifying Shorts" }, + "maintenance": { + "title": "Maintenance", + "batchLabel": "Videos re-checked per run", + "batchHint": "How many least-recently-checked videos the maintenance job re-validates each run. Higher = faster full cycle but more API quota (1 unit per 50 videos).", + "batchNote": "Default: {{default}}. ~233k videos ÷ this = days for a full re-check cycle." + }, "dot": { "running": "running now", "ok": "last run OK", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 7c7fac4..a3a1b9a 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -40,6 +40,12 @@ "backfill_deep": "Teljes előzmény behúzása", "probe": "Shorts besorolás" }, + "maintenance": { + "title": "Karbantartás", + "batchLabel": "Futásonként újraellenőrzött videók", + "batchHint": "Hány, legrégebben ellenőrzött videót ellenőriz újra a karbantartó job futásonként. Magasabb = gyorsabb teljes ciklus, de több API-kvóta (50 videónként 1 egység).", + "batchNote": "Alapérték: {{default}}. ~233k videó ÷ ez = a teljes újraellenőrzési ciklus napokban." + }, "dot": { "running": "most fut", "ok": "utolsó futás OK", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4dbb9b6..c316c70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -349,6 +349,12 @@ export interface SchedulerStatus { daily_budget: number; backfill_reserve: number; }; + maintenance: { + revalidate_batch: number; + revalidate_batch_default: number; + min: number; + max: number; + }; } export interface Account { @@ -474,6 +480,11 @@ export const api = { req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), runAllSchedulerJobs: (): Promise<{ started: string[] }> => req("/api/admin/scheduler/run-all", { method: "POST" }), + updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> => + req("/api/admin/scheduler/maintenance", { + method: "PATCH", + body: JSON.stringify({ revalidate_batch: revalidateBatch }), + }), // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => From d3c6ce659c5611608b7347e67ed6473c36176207 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 18 Jun 2026 04:37:20 +0200 Subject: [PATCH 2/2] feat(notifications): unify the two rail indicators into one inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the client-side transient bell into the inbox page so there's a single notification indicator. The inbox now has two groups — "System" (durable, server-backed) and "Activity" (client-side events with their Unhide/Unwatch/ Find-in-feed actions) — and the nav badge sums both unread counts. The separate rail bell (NotificationCenter) is removed. Activity items get a per-item clear (X) alongside the global Clear all. Unhiding or unwatching a video from anywhere — a card, the toast's Undo, or the inbox — now quietly resolves the original "Hidden/Watched X" entry (no duplicate "Unhidden X" toast, no stale entry with a dead action), via a new resolveVideo store helper. --- frontend/src/App.tsx | 4 +- frontend/src/components/Feed.tsx | 6 +- frontend/src/components/NavSidebar.tsx | 16 +- .../src/components/NotificationsPanel.tsx | 240 ++++++++++++++++-- frontend/src/i18n/locales/de/inbox.json | 2 + frontend/src/i18n/locales/en/inbox.json | 2 + frontend/src/i18n/locales/hu/inbox.json | 2 + frontend/src/lib/notifications.ts | 15 ++ 8 files changed, 246 insertions(+), 41 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ccfe17a..037cc89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -252,8 +252,6 @@ export default function App() { onOpenAbout={() => setAboutOpen(true)} onChangeLanguage={changeLanguage} language={i18n.language as LangCode} - filters={filters} - setFilters={setFilters} />
) : page === "notifications" ? ( - + ) : page === "settings" ? ( onState(id, "new") }, meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, }); + } else if (status === "new") { + // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve any + // stale hide/watch notice for this video so it doesn't linger with a dead action. + resolveVideo(id); } }, [qc] diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index f862adb..cef12ec 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; @@ -17,13 +17,13 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type FeedFilters, type Me } from "../lib/api"; +import { api, type Me } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; -import NotificationCenter from "./NotificationCenter"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -35,8 +35,6 @@ export default function NavSidebar({ onOpenAbout, onChangeLanguage, language, - filters, - setFilters, }: { me: Me; page: Page; @@ -44,8 +42,6 @@ export default function NavSidebar({ onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( @@ -125,7 +121,10 @@ export default function NavSidebar({ api.notificationUnreadCount, { intervalMs: 30000 } ); - const unread = unreadQuery.data?.count ?? 0; + // One indicator for both layers: durable server notifications + the client-side transient + // events (the former separate bell is now folded into the inbox page). + const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); + const unread = (unreadQuery.data?.count ?? 0) + clientUnread; type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // User-facing content modules vs. admin/system modules, separated by a divider in the rail. @@ -243,7 +242,6 @@ export default function NavSidebar({ > -
+ + + ) : watched ? ( +
+ +
+ ) : ( + + n.action && + !n.dismissed && ( + + ) + )} + + + + ); +} diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json index 807e91f..734869f 100644 --- a/frontend/src/i18n/locales/de/inbox.json +++ b/frontend/src/i18n/locales/de/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Alle löschen", "markRead": "Als gelesen markieren", "dismiss": "Verwerfen", + "sectionSystem": "System", + "sectionActivity": "Aktivität", "andMore": "und {{count}} weitere", "maintenance": { "title": "Videos entfernt", diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index 89d2fd8..8d7c7a6 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Clear all", "markRead": "Mark read", "dismiss": "Dismiss", + "sectionSystem": "System", + "sectionActivity": "Activity", "andMore": "and {{count}} more", "maintenance": { "title": "Videos removed", diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 9f144d3..3f6e6d8 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -7,6 +7,8 @@ "clearAll": "Összes törlése", "markRead": "Olvasottnak jelöl", "dismiss": "Elvetés", + "sectionSystem": "Rendszer", + "sectionActivity": "Tevékenység", "andMore": "és még {{count}}", "maintenance": { "title": "Videók eltávolítva", diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 530ce68..184d708 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -218,6 +218,21 @@ export function clearAll(): void { emit(); } +/** Remove a single history entry entirely (per-item clear in the notification center). */ +export function remove(id: number): void { + const before = items.length; + items = items.filter((n) => n.id !== id); + if (items.length !== before) emit(); +} + +/** Resolve any history entries tied to a video — called when it's unhidden/unwatched so the + * now-stale "Hidden/Watched X" notice disappears instead of lingering with a dead action. */ +export function resolveVideo(videoId: string): void { + const before = items.length; + items = items.filter((n) => n.meta?.videoId !== videoId); + if (items.length !== before) emit(); +} + export const getActiveToasts = (): Notification[] => cachedActive; export const getNotifications = (): Notification[] => cachedReversed; export const getUnreadCount = (): number => cachedUnread;