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/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({
>
-