Merge: promote dev to prod
Some checks failed
CI / frontend (push) Failing after 32s
CI / backend (push) Failing after 44s

This commit is contained in:
npeter83 2026-06-18 04:39:29 +02:00
commit 0c969a5bc5
34 changed files with 1704 additions and 301 deletions

View file

@ -1 +1 @@
0.8.0 0.9.0

View file

@ -0,0 +1,43 @@
"""video maintenance / validation columns
Revision ID: 0016_video_maintenance
Revises: 0015_scheduler_settings
Create Date: 2026-06-18
Adds the columns the maintenance/validation job needs to detect and retire unplayable
videos: videos.list?part=status fields (embeddable / privacy_status / upload_status) plus
the validation lifecycle (last_checked_at for rolling re-validation; unavailable_since +
unavailable_reason to flag a video as currently unplayable hidden from the feed
immediately, hard-deleted after a grace period). Purely additive; all columns nullable.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0016_video_maintenance"
down_revision: Union[str, None] = "0015_scheduler_settings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("videos", sa.Column("embeddable", sa.Boolean(), nullable=True))
op.add_column("videos", sa.Column("privacy_status", sa.String(length=16), nullable=True))
op.add_column("videos", sa.Column("upload_status", sa.String(length=16), nullable=True))
op.add_column("videos", sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("videos", sa.Column("unavailable_since", sa.DateTime(timezone=True), nullable=True))
op.add_column("videos", sa.Column("unavailable_reason", sa.String(length=24), nullable=True))
op.create_index("ix_videos_last_checked_at", "videos", ["last_checked_at"])
op.create_index("ix_videos_unavailable_since", "videos", ["unavailable_since"])
def downgrade() -> None:
op.drop_index("ix_videos_unavailable_since", table_name="videos")
op.drop_index("ix_videos_last_checked_at", table_name="videos")
op.drop_column("videos", "unavailable_reason")
op.drop_column("videos", "unavailable_since")
op.drop_column("videos", "last_checked_at")
op.drop_column("videos", "upload_status")
op.drop_column("videos", "privacy_status")
op.drop_column("videos", "embeddable")

View file

@ -0,0 +1,59 @@
"""per-user notification inbox (phase 1)
Revision ID: 0017_notifications
Revises: 0016_video_maintenance
Create Date: 2026-06-18
Adds the durable, server-backed per-user notification table behind the inbox module. The
JSON payload column is named `data` (SQLAlchemy's declarative Base reserves `metadata`).
First producer is the maintenance job's batched "videos removed" notice. Coexists with the
existing client-side transient bell.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0017_notifications"
down_revision: Union[str, None] = "0016_video_maintenance"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.String(length=32), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("read", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("dismissed", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now()
),
)
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
op.create_index("ix_notifications_type", "notifications", ["type"])
op.create_index("ix_notifications_read", "notifications", ["read"])
op.create_index("ix_notifications_created_at", "notifications", ["created_at"])
# Fast unread-badge count + inbox listing per user.
op.create_index(
"ix_notifications_user_read", "notifications", ["user_id", "read"]
)
def downgrade() -> None:
op.drop_index("ix_notifications_user_read", table_name="notifications")
op.drop_index("ix_notifications_created_at", table_name="notifications")
op.drop_index("ix_notifications_read", table_name="notifications")
op.drop_index("ix_notifications_type", table_name="notifications")
op.drop_index("ix_notifications_user_id", table_name="notifications")
op.drop_table("notifications")

View file

@ -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")

View file

@ -90,6 +90,17 @@ class Settings(BaseSettings):
autotag_interval_minutes: int = 30 autotag_interval_minutes: int = 30
subscriptions_resync_minutes: int = 360 subscriptions_resync_minutes: int = 360
playlist_sync_minutes: int = 360 playlist_sync_minutes: int = 360
# --- Maintenance / validation job ---
# Runs once a day by default (admin-tunable via the Scheduler dashboard). Grace periods
# are in days because that's the granularity that matters; the rolling re-validation
# re-checks the least-recently-checked N videos per run (1 unit / 50 videos), so on a
# ~233k catalog ~15000/day ≈ a 15-day full cycle for ~300 quota units.
maintenance_interval_minutes: int = 1440
maintenance_revalidate_batch: int = 15000
# Grace before a flagged-unavailable video is hard-deleted (cascades to states/playlist
# items). Deleted/private/paywalled get a recovery window; abandoned upcoming and
# confirmed-dead stuck-live have no age grace (deleted on the sweep that confirms them).
maintenance_grace_days: int = 7
# live_status values hidden from the feed by default. Completed-stream VODs # live_status values hidden from the feed by default. Completed-stream VODs
# ("was_live") are real watchable content and stay visible. # ("was_live") are real watchable content and stay visible.
feed_default_hidden_live: str = "live,upcoming" feed_default_hidden_live: str = "live,upcoming"

View file

@ -32,6 +32,7 @@ from app.routes import (
feed, feed,
health, health,
me, me,
notifications,
playlists, playlists,
quota, quota,
scheduler as scheduler_routes, scheduler as scheduler_routes,
@ -77,6 +78,7 @@ app.include_router(sync.router)
app.include_router(tags.router) app.include_router(tags.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(notifications.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router) app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)

View file

@ -203,6 +203,22 @@ class Video(Base):
String(16), default="none", server_default="none", index=True String(16), default="none", server_default="none", index=True
) )
enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# videos.list?part=status fields (populated during enrichment); used by the
# maintenance/validation job to decide whether a video is playable anywhere.
embeddable: Mapped[bool | None] = mapped_column(Boolean)
privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private
upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted
# Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation
# (least-recently-checked first); unavailable_since marks a video as currently
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
last_checked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True
)
unavailable_since: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True
)
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
@ -323,6 +339,9 @@ class AppState(Base):
sync_paused: Mapped[bool] = mapped_column( sync_paused: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" 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): class SchedulerSetting(Base):
@ -399,3 +418,36 @@ class PlaylistItem(Base):
) )
playlist: Mapped["Playlist"] = relationship(back_populates="items") playlist: Mapped["Playlist"] = relationship(back_populates="items")
class Notification(Base):
"""A durable, server-backed per-user notification (the inbox center, phase 1).
Distinct from the client-side transient "bell" (toasts kept only in localStorage):
these survive reloads and devices, and are produced server-side (first producer = the
maintenance/validation job's batched "N saved/playlisted videos removed" notice). The
column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves):
a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read`
and `dismissed` are separate: read clears the unread badge but keeps the row in the
inbox; dismissed removes it from the default view. Unread rows never auto-expire; read
ones are trimmed past a soft per-user cap (see app.notifications.trim_read)."""
__tablename__ = "notifications"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance"
title: Mapped[str] = mapped_column(String(255))
body: Mapped[str | None] = mapped_column(Text)
data: Mapped[dict | None] = mapped_column(JSON)
read: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
dismissed: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)

View file

@ -0,0 +1,58 @@
"""Helpers for the durable, server-backed per-user notification inbox (phase 1).
The inbox COEXISTS with the client-side transient "bell" (localStorage toasts): these rows
survive reloads/devices and are produced server-side. First producer is the maintenance
job. Unread rows never auto-expire; read rows are trimmed past a soft per-user cap so the
table can't grow without bound for a heavy user.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Notification
# Keep at most this many READ notifications per user; older read ones are pruned. Unread
# rows are never pruned (the user hasn't seen them yet).
READ_SOFT_CAP = 200
def create_notification(
db: Session,
user_id: int,
type: str,
title: str,
body: str | None = None,
data: dict | None = None,
*,
commit: bool = True,
) -> Notification:
"""Insert one notification for a user. Callers that batch many inserts in a single
transaction can pass commit=False and commit once themselves."""
notif = Notification(
user_id=user_id, type=type, title=title, body=body, data=data
)
db.add(notif)
if commit:
db.commit()
db.refresh(notif)
return notif
def trim_read(db: Session, user_id: int, cap: int = READ_SOFT_CAP) -> int:
"""Delete a user's oldest READ notifications beyond `cap`. Returns the number removed.
Idempotent and cheap; safe to call after marking things read."""
ids = (
db.execute(
select(Notification.id)
.where(Notification.user_id == user_id, Notification.read.is_(True))
.order_by(Notification.created_at.desc())
.offset(cap)
)
.scalars()
.all()
)
if not ids:
return 0
for nid in ids:
db.delete(db.get(Notification, nid))
db.commit()
return len(ids)

40
backend/app/progress.py Normal file
View file

@ -0,0 +1,40 @@
"""Lightweight, decoupled progress reporting for long-running scheduler jobs.
A job's work function calls `report(current, total, phase)`; the scheduler binds a sink (via
`bind()`) that forwards it into the in-memory activity the admin dashboard polls. A contextvar
holds the sink, so the sync code stays unaware of the scheduler and concurrent jobs (each in
its own thread/context) never cross wires. When no sink is bound (e.g. a manual sync endpoint),
`report` is a cheap no-op.
"""
import contextvars
from typing import Callable, Optional
Sink = Callable[[int, Optional[int], Optional[str]], None]
_sink: contextvars.ContextVar[Optional[Sink]] = contextvars.ContextVar(
"progress_sink", default=None
)
def report(current: int, total: int | None = None, phase: str | None = None) -> None:
"""Report job progress to the bound sink, if any. `total=None` means indeterminate."""
sink = _sink.get()
if sink is not None:
sink(current, total, phase)
class bind:
"""Context manager that binds a progress sink for the duration of a job run."""
def __init__(self, sink: Sink):
self._sink = sink
self._token = None
def __enter__(self) -> "bind":
self._token = _sink.set(self._sink)
return self
def __exit__(self, *exc) -> bool:
if self._token is not None:
_sink.reset(self._token)
return False

View file

@ -154,6 +154,11 @@ def _filtered_query(
state, and_(state.video_id == Video.id, state.user_id == user.id) state, and_(state.video_id == Video.id, state.user_id == user.id)
) )
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
# abandoned). They're either deleted after a grace period or unhidden if they recover;
# either way they shouldn't show in any feed view meanwhile.
query = query.where(Video.unavailable_since.is_(None))
if channel_id: if channel_id:
query = query.where(Video.channel_id == channel_id) query = query.where(Video.channel_id == channel_id)
if min_duration is not None: if min_duration is not None:

View file

@ -0,0 +1,136 @@
"""Per-user notification inbox (phase 1): the durable, server-backed center that coexists
with the client-side transient bell. Read-only listing plus read/dismiss/clear mutations.
All endpoints use `current_user` (not `require_human`): notifications and their read/dismiss
state are personal UI state with no quota or YouTube involvement, so the shared demo account
manages its own inbox normally."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import Notification, User
from app.notifications import trim_read
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
def _serialize(n: Notification) -> dict:
return {
"id": n.id,
"type": n.type,
"title": n.title,
"body": n.body,
"data": n.data,
"read": n.read,
"dismissed": n.dismissed,
"created_at": n.created_at.isoformat() if n.created_at else None,
}
@router.get("")
def list_notifications(
include_dismissed: bool = False,
limit: int = Query(default=100, le=500),
offset: int = 0,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
where = [Notification.user_id == user.id]
if not include_dismissed:
where.append(Notification.dismissed.is_(False))
total = db.scalar(
select(func.count()).select_from(Notification).where(*where)
) or 0
rows = (
db.execute(
select(Notification)
.where(*where)
.order_by(Notification.created_at.desc())
.offset(offset)
.limit(limit)
)
.scalars()
.all()
)
return {"items": [_serialize(n) for n in rows], "total": total}
@router.get("/unread_count")
def unread_count(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Cheap poll target for the nav badge (uses the (user_id, read) index)."""
count = db.scalar(
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == user.id,
Notification.read.is_(False),
Notification.dismissed.is_(False),
)
)
return {"count": count or 0}
def _owned(db: Session, notification_id: int, user: User) -> Notification:
n = db.get(Notification, notification_id)
if n is None or n.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
return n
@router.post("/{notification_id}/read")
def mark_read(
notification_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
n = _owned(db, notification_id, user)
n.read = True
db.commit()
trim_read(db, user.id)
return {"ok": True}
@router.post("/read_all")
def mark_all_read(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
db.execute(
update(Notification)
.where(Notification.user_id == user.id, Notification.read.is_(False))
.values(read=True)
)
db.commit()
trim_read(db, user.id)
return {"ok": True}
@router.post("/{notification_id}/dismiss")
def dismiss(
notification_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
n = _owned(db, notification_id, user)
# Dismiss implies read (it no longer counts toward the unread badge).
n.dismissed = True
n.read = True
db.commit()
return {"ok": True}
@router.post("/clear")
def clear_all(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Dismiss every (still-visible) notification for this user."""
db.execute(
update(Notification)
.where(Notification.user_id == user.id, Notification.dismissed.is_(False))
.values(dismissed=True, read=True)
)
db.commit()
return {"ok": True}

View file

@ -10,7 +10,15 @@ from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
from app.routes.admin import admin_user from app.routes.admin import admin_user
from app.scheduler import JOB_INTERVALS, MAX_INTERVAL, MIN_INTERVAL, apply_interval, scheduler_snapshot from app.scheduler import (
JOB_INTERVALS,
MAX_INTERVAL,
MIN_INTERVAL,
apply_interval,
scheduler_snapshot,
trigger_all,
trigger_job,
)
from app.sync.runner import estimate_deep_backfill from app.sync.runner import estimate_deep_backfill
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
@ -40,6 +48,52 @@ def set_job_interval(
return {"id": job_id, "interval_minutes": applied} return {"id": job_id, "interval_minutes": applied}
@router.post("/jobs/{job_id}/run")
def run_job_now(
job_id: str,
user: User = Depends(admin_user),
) -> dict:
"""Trigger a job to run immediately (in a background thread), independent of its
interval. 409 if it's already running, 404 for an unknown job. On completion the
triggering admin gets an inbox notification with the result."""
try:
result = trigger_job(job_id, actor_id=user.id)
except KeyError:
raise HTTPException(status_code=404, detail="Unknown job")
if result == "already_running":
raise HTTPException(status_code=409, detail="Job is already running")
return {"id": job_id, "started": True}
@router.post("/run-all")
def run_all_now(user: User = Depends(admin_user)) -> dict:
"""Trigger every job that isn't already running. Returns the ids actually started."""
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("") @router.get("")
def get_scheduler( def get_scheduler(
_: User = Depends(admin_user), db: Session = Depends(get_db) _: User = Depends(admin_user), db: Session = Depends(get_db)
@ -107,4 +161,10 @@ def get_scheduler(
"paused": state.is_sync_paused(db), "paused": state.is_sync_paused(db),
"queue": queue, "queue": queue,
"quota": quota_info, "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,
},
} }

View file

@ -1,18 +1,22 @@
"""Background scheduler: free RSS detection, enrichment of new videos, and quota-aware """Background scheduler: free RSS detection, enrichment of new videos, and quota-aware
recent-first / deep backfill. Runs inside the API process (single worker).""" recent-first / deep backfill. Runs inside the API process (single worker)."""
import contextvars
import logging import logging
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select from sqlalchemy import select
from app import quota from app import progress, quota
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.models import SchedulerSetting from app.models import SchedulerSetting
from app.notifications import create_notification
from app.state import is_sync_paused from app.state import is_sync_paused
from app.sync.autotag import run_autotag_all from app.sync.autotag import run_autotag_all
from app.sync.maintenance import run_maintenance
from app.sync.playlists import sync_all_playlists from app.sync.playlists import sync_all_playlists
from app.sync.runner import ( from app.sync.runner import (
run_deep_backfill, run_deep_backfill,
@ -34,6 +38,13 @@ _scheduler: BackgroundScheduler | None = None
_activity: dict[str, dict] = {} _activity: dict[str, dict] = {}
_activity_lock = threading.Lock() _activity_lock = threading.Lock()
# Set (per-thread) by a manual "run now" trigger to the id of the admin who clicked, so that
# run — and only that run, not the recurring scheduled ones — posts a completion notification
# to their inbox. Unset (None) for scheduled runs.
_manual_actor: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"manual_actor", default=None
)
# Each interval job's env/config default period (minutes). The admin can override any of # Each interval job's env/config default period (minutes). The admin can override any of
# these at runtime (stored in scheduler_settings, applied live); see load_intervals. # these at runtime (stored in scheduler_settings, applied live); see load_intervals.
JOB_INTERVALS: dict[str, int] = { JOB_INTERVALS: dict[str, int] = {
@ -44,6 +55,7 @@ JOB_INTERVALS: dict[str, int] = {
"shorts": settings.shorts_probe_interval_minutes, "shorts": settings.shorts_probe_interval_minutes,
"subscriptions": settings.subscriptions_resync_minutes, "subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes, "playlist_sync": settings.playlist_sync_minutes,
"maintenance": settings.maintenance_interval_minutes,
} }
# Sane bounds for an admin-set interval (minutes). # Sane bounds for an admin-set interval (minutes).
@ -88,24 +100,55 @@ def _record(name: str, **fields) -> None:
_activity.setdefault(name, {}).update(fields) _activity.setdefault(name, {}).update(fields)
def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | None) -> None:
"""Post a completion notice to the triggering admin's inbox (manual runs only). Best
effort a notification failure must never mask the job's own outcome."""
try:
create_notification(
db,
user_id=actor_id,
type="scheduler",
title=f"{job_id}: {status}", # English fallback; UI renders translated from data
body=summary,
data={"job_id": job_id, "status": status, "summary": summary},
)
except Exception:
db.rollback()
logger.exception("failed to write completion notification for job %s", job_id)
def _job(name: str, fn) -> None: def _job(name: str, fn) -> None:
db = SessionLocal() db = SessionLocal()
_record(name, running=True, last_started=_now_iso(), last_error=None) actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record(name, running=True, last_started=_now_iso(), last_error=None, progress=None)
def sink(current, total, phase):
_record(name, progress={"current": current, "total": total, "phase": phase})
try: try:
if is_sync_paused(db): if is_sync_paused(db):
logger.info("job %s skipped (sync paused)", name) logger.info("job %s skipped (sync paused)", name)
_record(name, running=False, status="skipped", last_finished=_now_iso(), _record(name, running=False, status="skipped", last_finished=_now_iso(),
last_result="paused") last_result="paused", progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused")
return return
with progress.bind(sink):
result = fn(db) result = fn(db)
summary = _summarize(result)
logger.info("job %s -> %s", name, result) logger.info("job %s -> %s", name, result)
_record(name, running=False, status="ok", last_finished=_now_iso(), _record(name, running=False, status="ok", last_finished=_now_iso(),
last_result=_summarize(result)) last_result=summary, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary)
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
logger.exception("job %s failed", name) logger.exception("job %s failed", name)
err = str(exc) or exc.__class__.__name__
_record(name, running=False, status="error", last_finished=_now_iso(), _record(name, running=False, status="error", last_finished=_now_iso(),
last_error=str(exc) or exc.__class__.__name__) last_error=err, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err)
finally: finally:
db.close() db.close()
@ -149,6 +192,7 @@ def scheduler_snapshot() -> dict:
"last_finished": a.get("last_finished"), "last_finished": a.get("last_finished"),
"last_result": a.get("last_result"), "last_result": a.get("last_result"),
"last_error": a.get("last_error"), "last_error": a.get("last_error"),
"progress": a.get("progress"),
} }
) )
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs} return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
@ -201,6 +245,66 @@ def _playlist_sync_job() -> None:
_job("playlist_sync", sync_all_playlists) _job("playlist_sync", sync_all_playlists)
def _maintenance_job() -> None:
def work(db):
with quota.attribute(None, "maintenance"):
return run_maintenance(db)
_job("maintenance", work)
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
JOB_FUNCS: dict[str, Callable[[], None]] = {
"rss_poll": _rss_job,
"enrich": _enrich_job,
"backfill": _backfill_job,
"autotag": _autotag_job,
"shorts": _shorts_job,
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
"maintenance": _maintenance_job,
}
def _is_running(job_id: str) -> bool:
with _activity_lock:
return bool(_activity.get(job_id, {}).get("running"))
def trigger_job(job_id: str, actor_id: int | None = None) -> str:
"""Run a job immediately in a background thread, independent of its interval schedule.
The wrapper handles its own DB session, activity tracking and pause-skip, so the live
dashboard reflects the run. Returns "started", "already_running", or raises KeyError for
an unknown job. Refusing a concurrent run keeps a manual trigger from overlapping a
scheduled run (APScheduler enforces max_instances=1 for the scheduled side).
`actor_id` (the admin who clicked "run now") is stashed in a contextvar inside the new
thread so the run posts a completion notification to that admin's inbox."""
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
if _is_running(job_id):
return "already_running"
def run() -> None:
if actor_id is not None:
_manual_actor.set(actor_id)
fn()
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
return "started"
def trigger_all(actor_id: int | None = None) -> list[str]:
"""Trigger every job that isn't already running; returns the ids actually started."""
started = []
for job_id in JOB_FUNCS:
if trigger_job(job_id, actor_id) == "started":
started.append(job_id)
return started
def start_scheduler() -> None: def start_scheduler() -> None:
global _scheduler global _scheduler
if not settings.scheduler_enabled or _scheduler is not None: if not settings.scheduler_enabled or _scheduler is not None:
@ -211,17 +315,8 @@ def start_scheduler() -> None:
iv = load_intervals(db) iv = load_intervals(db)
finally: finally:
db.close() db.close()
fns = {
"rss_poll": _rss_job,
"enrich": _enrich_job,
"backfill": _backfill_job,
"autotag": _autotag_job,
"shorts": _shorts_job,
"subscriptions": _subscriptions_job,
"playlist_sync": _playlist_sync_job,
}
scheduler = BackgroundScheduler(timezone="UTC") scheduler = BackgroundScheduler(timezone="UTC")
for job_id, fn in fns.items(): for job_id, fn in JOB_FUNCS.items():
scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id) scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler

View file

@ -3,10 +3,15 @@ import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models import AppState from app.models import AppState
log = logging.getLogger("subfeed.state") 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: def _row(db: Session) -> AppState:
row = db.get(AppState, 1) row = db.get(AppState, 1)
@ -27,3 +32,20 @@ def set_sync_paused(db: Session, paused: bool) -> None:
db.add(row) db.add(row)
db.commit() db.commit()
log.info("Background sync %s", "paused" if paused else "resumed") 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

View file

@ -0,0 +1,249 @@
"""Maintenance / validation job: detect videos that can't be played anywhere and retire
them safely.
Lifecycle (no permanent soft-delete state just a grace timer before a hard delete):
1. Re-check flagged videos (those with unavailable_since set). If a video has recovered
(e.g. a private video made public again), clear the flag. Otherwise, if its grace
period has elapsed, HARD DELETE it (cascades to video_states / playlist_items). Before
deleting, gather which users had it saved or in a playlist so we can tell them.
2. Rolling re-validation: re-fetch the least-recently-checked N currently-available
videos. Newly-unplayable ones get flagged (hidden from the feed immediately via
unavailable_since); the grace timer starts now and they're deleted in a later run if
they don't recover.
Detection is ~free: a video missing from the videos.list response is deleted-or-private.
We never delete a live broadcast that still reports viewers (a legit 24/7 stream); we only
flag an `upcoming` premiere that blew past its scheduled start and never went live.
Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect the same
backfill reserve as the other jobs so interactive syncs never starve.
"""
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
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
log = logging.getLogger("subfeed.maintenance")
# Reasons recorded on Video.unavailable_reason. "removed" = absent from videos.list
# (deleted or made private); "abandoned" = an upcoming premiere that never went live.
GRACE_DAYS_BY_REASON = {
"removed": settings.maintenance_grace_days,
"abandoned": 0, # already past-due when detected; deleted on the confirming sweep
}
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
def _now() -> datetime:
return datetime.now(timezone.utc)
def _grace_days(reason: str | None) -> int:
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
def _is_abandoned_upcoming(item: dict) -> bool:
"""True for an `upcoming` broadcast whose scheduled start is >2 days past and which
never actually went live (no actualStartTime)."""
snippet = item.get("snippet", {})
if snippet.get("liveBroadcastContent") != "upcoming":
return False
live = item.get("liveStreamingDetails") or {}
if live.get("actualStartTime"):
return False # it did go live at some point — not abandoned
scheduled = parse_dt(live.get("scheduledStartTime"))
if scheduled is None:
return False
return _now() - scheduled > timedelta(days=2)
def _evaluate(video: Video, item: dict | None) -> str | None:
"""Given a fresh videos.list item (or None if absent), return an unavailable reason,
or None if the video is fine. Applies fresh metadata as a side effect when present."""
if item is None:
return "removed"
apply_video_details(video, item)
if _is_abandoned_upcoming(item):
return "abandoned"
# A still-"live" broadcast is kept (a legit 24/7 stream); ended streams have already
# settled to was_live via apply_video_details. Nothing else here is a delete trigger —
# embeddable=false only blocks our in-app embed, not playback on youtube.com.
return None
def _collect_user_impact(db: Session, videos: list[Video]) -> dict[int, list[dict]]:
"""For videos about to be deleted, map user_id -> list of {id, title, reason} for the
videos that user had saved or in one of their playlists. Must run BEFORE the delete,
while the video_states / playlist_items rows still exist."""
by_id = {v.id: v for v in videos}
ids = list(by_id)
impact: dict[int, set[str]] = {}
if not ids:
return {}
# Saved (VideoState.status == "saved").
for user_id, video_id in db.execute(
select(VideoState.user_id, VideoState.video_id).where(
VideoState.video_id.in_(ids), VideoState.status == "saved"
)
):
impact.setdefault(user_id, set()).add(video_id)
# In any of the user's playlists (covers watch-later, which is a playlist).
for user_id, video_id in db.execute(
select(Playlist.user_id, PlaylistItem.video_id)
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
.where(PlaylistItem.video_id.in_(ids))
):
impact.setdefault(user_id, set()).add(video_id)
return {
user_id: [
{
"id": vid,
"title": by_id[vid].title,
"reason": by_id[vid].unavailable_reason,
}
for vid in vids
]
for user_id, vids in impact.items()
}
def _notify_removed(db: Session, impact: dict[int, list[dict]]) -> int:
"""One batched notification per affected user (never per-video)."""
for user_id, videos in impact.items():
n = len(videos)
create_notification(
db,
user_id=user_id,
type="maintenance",
title="Videos removed",
body=(
f"{n} saved or playlisted "
f"{'video was' if n == 1 else 'videos were'} removed because "
"they are no longer available on YouTube."
),
data={"count": n, "videos": videos},
commit=False,
)
db.commit()
return len(impact)
def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
"""Phase 1: recover or hard-delete videos already flagged unavailable."""
flagged = (
db.execute(select(Video).where(Video.unavailable_since.is_not(None)))
.scalars()
.all()
)
if not flagged:
return {"checked": 0, "recovered": 0, "deleted": 0, "notified": 0}
progress.report(0, len(flagged), "recheck")
items = {it["id"]: it for it in yt.get_videos([v.id for v in flagged])}
now = _now()
recovered = 0
to_delete: list[Video] = []
for video in flagged:
reason = _evaluate(video, items.get(video.id))
video.last_checked_at = now
if reason is None:
# Recovered: unhide.
video.unavailable_since = None
video.unavailable_reason = None
recovered += 1
continue
# Still unavailable; keep the original reason/timer unless it changed.
if reason != video.unavailable_reason:
video.unavailable_reason = reason
deadline = video.unavailable_since + timedelta(days=_grace_days(reason))
if now >= deadline:
to_delete.append(video)
db.commit() # persist recoveries / reason updates / last_checked_at
notified = 0
deleted = 0
if to_delete:
impact = _collect_user_impact(db, to_delete)
for video in to_delete:
db.delete(video)
db.commit() # cascades to video_states / playlist_items
deleted = len(to_delete)
notified = _notify_removed(db, impact)
return {
"checked": len(flagged),
"recovered": recovered,
"deleted": deleted,
"notified": notified,
}
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 = get_maintenance_batch(db) # admin override, else config default
flagged_new = 0
checked = 0
now = _now()
# Process in videos.list-sized pages so we commit incrementally and stop on low quota.
while checked < batch:
if quota.remaining_today(db) <= settings.backfill_quota_reserve:
break
page = (
db.execute(
select(Video)
.where(Video.unavailable_since.is_(None))
.order_by(
Video.last_checked_at.is_(None).desc(), # never-checked first
Video.last_checked_at.asc(),
)
.limit(min(50, batch - checked))
)
.scalars()
.all()
)
if not page:
break
items = {it["id"]: it for it in yt.get_videos([v.id for v in page])}
for video in page:
reason = _evaluate(video, items.get(video.id))
video.last_checked_at = now
if reason is not None:
video.unavailable_since = now
video.unavailable_reason = reason
flagged_new += 1
db.commit()
checked += len(page)
progress.report(checked, batch, "revalidate")
return {"checked": checked, "flagged": flagged_new}
def run_maintenance(db: Session) -> dict:
"""Entry point invoked by the scheduler. Quota-attributed to the system (no actor)."""
user = get_service_user(db)
if user is None:
return {"reason": "no service user"}
with YouTubeClient(db, user) as yt:
phase1 = _recheck_flagged(db, yt)
phase2 = (
_revalidate_rolling(db, yt)
if quota.remaining_today(db) > settings.backfill_quota_reserve
else {"checked": 0, "flagged": 0, "skipped": "low quota"}
)
return {
"rechecked": phase1["checked"],
"recovered": phase1["recovered"],
"deleted": phase1["deleted"],
"notified": phase1["notified"],
"revalidated": phase2["checked"],
"flagged": phase2["flagged"],
}

View file

@ -9,7 +9,7 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app import progress, quota
from app.config import settings from app.config import settings
log = logging.getLogger("subfeed.sync") log = logging.getLogger("subfeed.sync")
@ -64,6 +64,7 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
break break
n = enrich_pending(db, yt) n = enrich_pending(db, yt)
total += n total += n
progress.report(total, None, "enrich") # total unknown (drains until empty)
if n == 0: if n == 0:
break break
# Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended # Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
@ -121,6 +122,7 @@ def run_recent_backfill(
try: try:
videos_added += backfill_channel_recent(db, yt, channel) videos_added += backfill_channel_recent(db, yt, channel)
processed += 1 processed += 1
progress.report(processed, len(channels), "backfill_recent")
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Recent backfill failed for channel %s", channel.id) log.exception("Recent backfill failed for channel %s", channel.id)
@ -166,6 +168,7 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
try: try:
videos_added += backfill_channel_deep(db, yt, channel, max_pages) videos_added += backfill_channel_deep(db, yt, channel, max_pages)
processed += 1 processed += 1
progress.report(processed, len(channels), "backfill_deep")
except Exception: except Exception:
db.rollback() db.rollback()
log.exception("Deep backfill failed for channel %s", channel.id) log.exception("Deep backfill failed for channel %s", channel.id)

View file

@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress
from app.config import settings from app.config import settings
from app.models import Channel, Video from app.models import Channel, Video
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
@ -217,6 +218,7 @@ def apply_video_details(video: Video, item: dict) -> None:
stats = item.get("statistics", {}) stats = item.get("statistics", {})
topics = item.get("topicDetails", {}) topics = item.get("topicDetails", {})
live = item.get("liveStreamingDetails") live = item.get("liveStreamingDetails")
status = item.get("status", {})
video.title = snippet.get("title") or video.title video.title = snippet.get("title") or video.title
video.description = snippet.get("description") video.description = snippet.get("description")
@ -232,6 +234,11 @@ def apply_video_details(video: Video, item: dict) -> None:
"defaultAudioLanguage" "defaultAudioLanguage"
) )
video.live_status = _live_status(snippet, live) video.live_status = _live_status(snippet, live)
# status part: used by the maintenance/validation job to judge playability.
if status:
video.embeddable = status.get("embeddable")
video.privacy_status = status.get("privacyStatus")
video.upload_status = status.get("uploadStatus")
# is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification). # is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification).
@ -359,5 +366,6 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
probed += 1 probed += 1
if result: if result:
shorts += 1 shorts += 1
progress.report(probed, len(candidates), "probe")
db.commit() db.commit()
return {"probed": probed, "shorts": shorts} return {"probed": probed, "shorts": shorts}

View file

@ -327,7 +327,7 @@ class YouTubeClient:
data = self._get( data = self._get(
"videos", "videos",
{ {
"part": "snippet,contentDetails,statistics,topicDetails,liveStreamingDetails", "part": "snippet,contentDetails,statistics,topicDetails,liveStreamingDetails,status",
"id": ",".join(batch), "id": ",".join(batch),
"maxResults": 50, "maxResults": 50,
}, },

View file

@ -10,7 +10,7 @@ import {
saveLocalTheme, saveLocalTheme,
type ThemePrefs, type ThemePrefs,
} from "./lib/theme"; } from "./lib/theme";
import { hasFilterParams, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState"; import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
import { import {
loadLayout, loadLayout,
normalizeLayout, normalizeLayout,
@ -29,6 +29,7 @@ import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
@ -60,15 +61,7 @@ function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage(); if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY); const stored = localStorage.getItem(PAGE_KEY);
if ( return isPage(stored) ? stored : "feed";
stored === "channels" ||
stored === "stats" ||
stored === "playlists" ||
stored === "settings" ||
stored === "scheduler"
)
return stored;
return "feed";
} }
function loadStoredFilters(): FeedFilters { function loadStoredFilters(): FeedFilters {
@ -259,8 +252,6 @@ export default function App() {
onOpenAbout={() => setAboutOpen(true)} onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage} onChangeLanguage={changeLanguage}
language={i18n.language as LangCode} language={i18n.language as LangCode}
filters={filters}
setFilters={setFilters}
/> />
<div className="relative flex-1 min-w-0 flex flex-col"> <div className="relative flex-1 min-w-0 flex flex-col">
<Header <Header
@ -311,6 +302,8 @@ export default function App() {
<Scheduler /> <Scheduler />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} />
) : page === "settings" ? ( ) : page === "settings" ? (
<SettingsPanel <SettingsPanel
me={meQuery.data!} me={meQuery.data!}

View file

@ -4,7 +4,7 @@ import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-quer
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react"; import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
@ -180,6 +180,10 @@ export default function Feed({
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, 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] [qc]

View file

@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Activity, Activity,
BarChart3, BarChart3,
Bell,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Home, Home,
@ -16,12 +17,13 @@ import {
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } 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 { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import NotificationCenter from "./NotificationCenter";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // 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 // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -33,8 +35,6 @@ export default function NavSidebar({
onOpenAbout, onOpenAbout,
onChangeLanguage, onChangeLanguage,
language, language,
filters,
setFilters,
}: { }: {
me: Me; me: Me;
page: Page; page: Page;
@ -42,8 +42,6 @@ export default function NavSidebar({
onOpenAbout: () => void; onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void; onChangeLanguage: (code: LangCode) => void;
language: LangCode; language: LangCode;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>( const [collapsed, setCollapsed] = useState<boolean>(
@ -116,12 +114,25 @@ export default function NavSidebar({
} }
} }
type NavItem = { page: Page; icon: typeof Home; label: string }; // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(
["notif-unread"],
api.notificationUnreadCount,
{ intervalMs: 30000 }
);
// 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. // User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = [ const userItems: NavItem[] = [
{ page: "feed", icon: Home, label: t("header.account.feed") }, { page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
{ page: "notifications", icon: Bell, label: t("inbox.navLabel"), badge: unread },
]; ];
const systemItems: NavItem[] = const systemItems: NavItem[] =
me.role === "admin" me.role === "admin"
@ -135,20 +146,47 @@ export default function NavSidebar({
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0]; const name = me.display_name ?? me.email.split("@")[0];
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => ( const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
// On the active row the background is the accent colour, so a same-accent badge would be
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
const show = !!badge && badge > 0;
return (
<button <button
key={p} key={p}
onClick={() => setPage(p)} onClick={() => setPage(p)}
title={collapsed ? label : undefined} title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${ className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card" active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`} }`}
> >
<Icon className="w-[18px] h-[18px] shrink-0" /> <span className="relative shrink-0">
<Icon className="w-[18px] h-[18px]" />
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring
matches the row background so it reads cleanly whether the row is active or not. */}
{show && collapsed && (
<span
className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
}`}
>
{badge > 9 ? "9+" : badge}
</span>
)}
</span>
{!collapsed && <span className="truncate">{label}</span>} {!collapsed && <span className="truncate">{label}</span>}
{show && !collapsed && (
<span
className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
>
{badge > 99 ? "99+" : badge}
</span>
)}
</button> </button>
); );
};
return ( return (
<nav <nav
@ -204,7 +242,6 @@ export default function NavSidebar({
> >
<Info className="w-5 h-5" /> <Info className="w-5 h-5" />
</button> </button>
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
</div> </div>
<button <button
onClick={() => setPage("settings")} onClick={() => setPage("settings")}

View file

@ -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<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
// portals it to <body> 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(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{el}
</div>,
document.body
);
}
return (
<div className="relative" ref={wrap}>
<button
ref={btnRef}
onClick={toggle}
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Bell className="w-5 h-5" />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold grid place-items-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
{open &&
renderPanel(
<div
ref={panelRef}
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
}`}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
title={t("notifications.clearAll")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("notifications.clear")}
</button>
)}
</div>
<div className="overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted">{t("notifications.empty")}</div>
) : (
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 (
<div
key={n.id}
className={`flex items-start gap-2.5 px-3 py-2.5 border-b border-border/60 ${
needsAction ? "bg-accent/5" : ""
}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relTime(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => locateHidden(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => revertState(hidden, "notifications.unhidden")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => revertState(watched, "notifications.unwatched")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismiss(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,371 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, CheckCheck, Eye, RotateCcw, Search, Trash2, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import {
clearAll as clearClient,
dismiss as dismissClient,
getNotifications,
getUnreadCount,
markAllRead as markAllClientRead,
remove as removeClient,
resolveVideo,
subscribe,
type Notification as ClientNotif,
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
// their inline undo/find actions) that used to live behind a separate bell — folded in here so
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), t);
}
function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string {
const secs = Math.max(0, Math.round((Date.now() - ms) / 1000));
// Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes.
if (secs < 60) return t("notifications.time.justNow");
const mins = Math.round(secs / 60);
if (mins < 60) return t("notifications.time.minutes", { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t("notifications.time.hours", { count: hours });
return t("notifications.time.days", { count: Math.round(hours / 24) });
}
export default function NotificationsPanel({
filters,
setFilters,
setPage,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
() => api.notifications(),
{ intervalMs: POLL_MS }
);
const serverItems = q.data?.items ?? [];
const clientItems = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
// Opening the center marks the (ephemeral) client items read, matching the old bell. Server
// items stay unread until acted on individually.
useEffect(() => {
markAllClientRead();
}, []);
const refresh = () => {
qc.invalidateQueries({ queryKey: ["notifications"] });
qc.invalidateQueries({ queryKey: ["notif-unread"] });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
const serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0;
const hasUnread = serverUnread || clientUnread > 0;
function markAll() {
markAllClientRead();
if (serverUnread) readAllMut.mutate();
else refresh();
}
function clearEverything() {
clearClient();
clearMut.mutate();
}
// "Find in feed" / undo, ported from the old bell.
function locateHidden(meta: VideoHiddenMeta) {
setFilters({
...filters,
show: "hidden",
channelId: meta.channelId || undefined,
channelName: meta.channelName || undefined,
});
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
.catch(() => {});
}
return (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
<div className="text-xs text-muted">{t("inbox.subtitle")}</div>
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
onClick={clearEverything}
disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</div>
)}
</div>
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
<div className="glass rounded-2xl p-10 text-center text-muted">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
{t("inbox.empty")}
</div>
) : (
<div className="space-y-4">
{serverItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionSystem")}
</div>
<div className="flex flex-col gap-2">
{serverItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
t={t}
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
/>
))}
</div>
</section>
)}
{clientItems.length > 0 && (
<section>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("inbox.sectionActivity")}
</div>
<div className="flex flex-col gap-2">
{clientItems.map((n) => (
<ClientActivityRow
key={n.id}
n={n}
t={t}
onFind={locateHidden}
onRevert={revertState}
onRemove={() => removeClient(n.id)}
/>
))}
</div>
</section>
)}
</div>
)}
</div>
);
}
function NotificationRow({
n,
t,
onRead,
onDismiss,
}: {
n: AppNotification;
t: (k: string, o?: any) => string;
onRead: () => void;
onDismiss: () => 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)
? n.data!.videos
: [];
// Known notification types are rendered from i18n (so they're trilingual) using the typed
// 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";
let title = n.title;
let body = n.body;
if (isMaintenance) {
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: n.data!.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id);
title = t(
n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk",
{ job }
);
body = n.data!.summary || n.body;
}
return (
<div
className={`glass rounded-xl p-3.5 flex items-start gap-3 transition ${
n.read ? "opacity-70" : ""
}`}
>
{!n.read && <span className="mt-1.5 w-2 h-2 rounded-full bg-accent shrink-0" aria-hidden />}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-[11px] text-muted shrink-0">
{relativeTime(n.created_at, t)}
</span>
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
{videos.length > 0 && (
<ul className="mt-2 text-xs text-muted list-disc pl-4 space-y-0.5">
{videos.slice(0, 8).map((v) => (
<li key={v.id} className="truncate">
{v.title || v.id}
</li>
))}
{videos.length > 8 && (
<li className="list-none text-muted/70">
{t("inbox.andMore", { count: videos.length - 8 })}
</li>
)}
</ul>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<button
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
)}
<button
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
function ClientActivityRow({
n,
t,
onFind,
onRevert,
onRemove,
}: {
n: ClientNotif;
t: (k: string, o?: any) => string;
onFind: (meta: VideoHiddenMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRemove: () => void;
}) {
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 (
<div
className={`glass rounded-xl p-3.5 flex items-start gap-3 ${needsAction ? "ring-1 ring-accent/30" : ""}`}
>
<Icon className={`w-4 h-4 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relativeFromMs(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}
</span>
)}
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</div>
) : (
n.action &&
!n.dismissed && (
<button
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
)
)}
</div>
<button
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View file

@ -73,14 +73,45 @@ function StatusLegend() {
); );
} }
function JobProgress({ p }: { p: NonNullable<SchedulerJob["progress"]> }) {
const { t } = useTranslation();
const phase = p.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : null;
const pct =
p.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
return (
<div className="mt-1.5">
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
<span className="truncate">{phase}</span>
<span className="tabular-nums shrink-0">
{p.total != null
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
: p.current.toLocaleString()}
</span>
</div>
<div className="h-1.5 rounded-full bg-border overflow-hidden">
{pct != null ? (
<div className="h-full rounded-full bg-accent transition-[width]" style={{ width: `${pct}%` }} />
) : (
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
)}
</div>
</div>
);
}
function JobRow({ function JobRow({
job, job,
onSave, onSave,
saving, saving,
onRun,
runDisabled,
}: { }: {
job: SchedulerJob; job: SchedulerJob;
onSave: (minutes: number) => void; onSave: (minutes: number) => void;
saving: boolean; saving: boolean;
onRun: () => void;
runDisabled: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@ -153,6 +184,7 @@ function JobRow({
<span> · {job.last_result}</span> <span> · {job.last_result}</span>
) : null} ) : null}
</div> </div>
{job.running && job.progress && <JobProgress p={job.progress} />}
</div> </div>
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums"> <div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
{job.running ? ( {job.running ? (
@ -166,6 +198,70 @@ function JobRow({
"—" "—"
)} )}
</div> </div>
<Tooltip hint={t("scheduler.runNow")}>
<button
onClick={onRun}
disabled={job.running || runDisabled}
aria-label={t("scheduler.runNow")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
>
<Play className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
}
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 (
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm flex-wrap">
<Database className="w-4 h-4 text-muted shrink-0" />
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t("scheduler.maintenance.batchLabel")}
</span>
</Tooltip>
<span className="flex-1" />
<input
type="number"
min={m.min}
max={m.max}
step={1000}
value={val}
onChange={(e) => 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"
/>
<button
onClick={save}
disabled={!dirty || saving}
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
{t("common.save")}
</button>
</div>
<div className="text-[11px] text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
</div>
</div> </div>
); );
} }
@ -227,6 +323,27 @@ export default function Scheduler() {
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }), onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
}); });
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
onSuccess: (_d, jobId) => {
notify({ level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }) });
qc.invalidateQueries({ queryKey: ["scheduler"] });
},
});
const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(),
onSuccess: (res) => {
notify({ level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }) });
qc.invalidateQueries({ queryKey: ["scheduler"] });
},
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
});
if (q.isLoading && !data) if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>; return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data) if (!data)
@ -260,6 +377,16 @@ export default function Scheduler() {
</div> </div>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<button
onClick={() => runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Play className="w-4 h-4" />
{t("scheduler.runAll")}
</button>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}> <Tooltip hint={t("scheduler.pauseHint")}>
<button <button
onClick={() => pauseResume.mutate(data.paused)} onClick={() => pauseResume.mutate(data.paused)}
@ -290,6 +417,8 @@ export default function Scheduler() {
job={job} job={job}
saving={intervalMut.isPending} saving={intervalMut.isPending}
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })} onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
onRun={() => runMut.mutate(job.id)}
runDisabled={data.paused || runAllMut.isPending}
/> />
))} ))}
</div> </div>
@ -361,6 +490,16 @@ export default function Scheduler() {
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div> <div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div> </div>
</div> </div>
{/* Maintenance settings */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
<MaintenanceSettings
m={data.maintenance}
onSave={(batch) => batchMut.mutate(batch)}
saving={batchMut.isPending}
/>
</div>
</div> </div>
); );
} }

View file

@ -0,0 +1,22 @@
{
"navLabel": "Benachrichtigungen",
"title": "Benachrichtigungen",
"subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.",
"empty": "Alles erledigt.",
"markAllRead": "Alle als gelesen markieren",
"clearAll": "Alle löschen",
"markRead": "Als gelesen markieren",
"dismiss": "Verwerfen",
"sectionSystem": "System",
"sectionActivity": "Aktivität",
"andMore": "und {{count}} weitere",
"maintenance": {
"title": "Videos entfernt",
"body_one": "{{count}} gespeichertes oder in einer Playlist befindliches Video wurde entfernt, weil es auf YouTube nicht mehr verfügbar ist.",
"body_other": "{{count}} gespeicherte oder in Playlists befindliche Videos wurden entfernt, weil sie auf YouTube nicht mehr verfügbar sind."
},
"jobDone": {
"titleOk": "{{job}} abgeschlossen",
"titleError": "{{job}} fehlgeschlagen"
}
}

View file

@ -25,6 +25,27 @@
"minutes": "Min", "minutes": "Min",
"editInterval": "Klicken, um das Intervall zu ändern", "editInterval": "Klicken, um das Intervall zu ändern",
"intervalFailed": "Intervall konnte nicht geändert werden", "intervalFailed": "Intervall konnte nicht geändert werden",
"runNow": "Jetzt ausführen",
"runAll": "Alle jetzt starten",
"runAllHint": "Alle Jobs jetzt ausführen, zusätzlich zu ihren Zeitplänen.",
"runAllPausedHint": "Setze den Planer fort, um Jobs auszuführen (pausiert werden sie übersprungen).",
"triggered": "{{job}} gestartet",
"triggeredAll_one": "{{count}} Job gestartet",
"triggeredAll_other": "{{count}} Jobs gestartet",
"phase": {
"recheck": "Markierte Videos erneut prüfen",
"revalidate": "Videos erneut validieren",
"enrich": "Videos anreichern",
"backfill_recent": "Neue Uploads nachladen",
"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": { "dot": {
"running": "läuft gerade", "running": "läuft gerade",
"ok": "letzter Lauf OK", "ok": "letzter Lauf OK",
@ -39,7 +60,8 @@
"autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.", "autotag": "Erkennt Sprache/Themen jedes Kanals und vergibt automatische Tags. Fällt er aus, bekommen neue Kanäle keine Auto-Tags, sodass Sprach-/Themenfilter sie verfehlen.",
"shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.", "shorts": "Prüft youtube.com/shorts, um Shorts zu markieren. Fällt er aus, werden Shorts im Feed nicht von normalen Videos getrennt.",
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht." "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)", "rss_poll": "RSS-Abfrage (neue Uploads)",
@ -48,7 +70,8 @@
"autotag": "Automatisches Tagging", "autotag": "Automatisches Tagging",
"shorts": "Shorts-Klassifizierung", "shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync", "subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync" "playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung"
}, },
"queue": { "queue": {
"recentPending": "Zu synchronisierende Kanäle", "recentPending": "Zu synchronisierende Kanäle",

View file

@ -0,0 +1,22 @@
{
"navLabel": "Notifications",
"title": "Notifications",
"subtitle": "Updates from your library and the system.",
"empty": "You're all caught up.",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
"markRead": "Mark read",
"dismiss": "Dismiss",
"sectionSystem": "System",
"sectionActivity": "Activity",
"andMore": "and {{count}} more",
"maintenance": {
"title": "Videos removed",
"body_one": "{{count}} saved or playlisted video was removed because it's no longer available on YouTube.",
"body_other": "{{count}} saved or playlisted videos were removed because they're no longer available on YouTube."
},
"jobDone": {
"titleOk": "{{job}} finished",
"titleError": "{{job}} failed"
}
}

View file

@ -25,6 +25,27 @@
"minutes": "min", "minutes": "min",
"editInterval": "Click to change how often this runs", "editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval", "intervalFailed": "Couldn't change the interval",
"runNow": "Run now",
"runAll": "Start all now",
"runAllHint": "Run every job now, in addition to their schedules.",
"runAllPausedHint": "Resume the scheduler to run jobs (they're skipped while paused).",
"triggered": "Started {{job}}",
"triggeredAll_one": "Started {{count}} job",
"triggeredAll_other": "Started {{count}} jobs",
"phase": {
"recheck": "Re-checking flagged videos",
"revalidate": "Re-validating videos",
"enrich": "Enriching videos",
"backfill_recent": "Backfilling recent uploads",
"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": { "dot": {
"running": "running now", "running": "running now",
"ok": "last run OK", "ok": "last run OK",
@ -39,7 +60,8 @@
"autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.", "autotag": "Detects each channel's language/topics and applies automatic tags. If it stops, new channels get no auto tags, so language/topic filters miss them.",
"shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.", "shorts": "Probes youtube.com/shorts to mark which videos are Shorts. If it stops, Shorts aren't separated from normal videos in the feed.",
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally." "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS poll (new uploads)", "rss_poll": "RSS poll (new uploads)",
@ -48,7 +70,8 @@
"autotag": "Auto-tagging", "autotag": "Auto-tagging",
"shorts": "Shorts classification", "shorts": "Shorts classification",
"subscriptions": "Subscription resync", "subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync" "playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation"
}, },
"queue": { "queue": {
"recentPending": "Channels to sync", "recentPending": "Channels to sync",

View file

@ -0,0 +1,22 @@
{
"navLabel": "Értesítések",
"title": "Értesítések",
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
"empty": "Nincs új értesítés.",
"markAllRead": "Összes olvasott",
"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",
"body_one": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhető a YouTube-on.",
"body_other": "{{count}} mentett vagy lejátszási listás videó eltávolítva, mert már nem elérhetők a YouTube-on."
},
"jobDone": {
"titleOk": "{{job}} kész",
"titleError": "{{job}} hibázott"
}
}

View file

@ -25,6 +25,27 @@
"minutes": "perc", "minutes": "perc",
"editInterval": "Kattints a gyakoriság módosításához", "editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot", "intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"runNow": "Futtatás most",
"runAll": "Mind indítása most",
"runAllHint": "Minden feladat azonnali futtatása, az ütemezésükön felül.",
"runAllPausedHint": "Folytasd az ütemezőt a futtatáshoz (szüneteltetve a feladatok kimaradnak).",
"triggered": "Elindítva: {{job}}",
"triggeredAll_one": "{{count}} feladat elindítva",
"triggeredAll_other": "{{count}} feladat elindítva",
"phase": {
"recheck": "Megjelölt videók újraellenőrzése",
"revalidate": "Videók újraellenőrzése",
"enrich": "Videók dúsítása",
"backfill_recent": "Friss feltöltések behúzása",
"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": { "dot": {
"running": "most fut", "running": "most fut",
"ok": "utolsó futás OK", "ok": "utolsó futás OK",
@ -39,7 +60,8 @@
"autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.", "autotag": "Felismeri a csatornák nyelvét/témáit, és automatikus címkéket ad. Ha leáll, az új csatornák címke nélkül maradnak, így a nyelv/téma szűrők kihagyják őket.",
"shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.", "shorts": "A youtube.com/shorts próbával jelöli, mely videók Shorts-ok. Ha leáll, a Shorts-ok nem különülnek el a normál videóktól a hírfolyamban.",
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan." "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak."
}, },
"jobs": { "jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)", "rss_poll": "RSS-lekérdezés (új feltöltések)",
@ -48,7 +70,8 @@
"autotag": "Automatikus címkézés", "autotag": "Automatikus címkézés",
"shorts": "Shorts-besorolás", "shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja", "subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja" "playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés"
}, },
"queue": { "queue": {
"recentPending": "Szinkronra váró csatorna", "recentPending": "Szinkronra váró csatorna",

View file

@ -325,6 +325,9 @@ export interface SchedulerJob {
last_finished: string | null; last_finished: string | null;
last_result: string | null; last_result: string | null;
last_error: string | null; last_error: string | null;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
progress: { current: number; total: number | null; phase: string | null } | null;
} }
export interface SchedulerStatus { export interface SchedulerStatus {
@ -346,6 +349,12 @@ export interface SchedulerStatus {
daily_budget: number; daily_budget: number;
backfill_reserve: number; backfill_reserve: number;
}; };
maintenance: {
revalidate_batch: number;
revalidate_batch_default: number;
min: number;
max: number;
};
} }
export interface Account { export interface Account {
@ -356,6 +365,19 @@ export interface Account {
active: boolean; active: boolean;
} }
// A durable, server-backed notification (the inbox center). Distinct from the client-side
// transient toast/bell, which lives only in localStorage.
export interface AppNotification {
id: number;
type: string;
title: string;
body: string | null;
data: Record<string, any> | null;
read: boolean;
dismissed: boolean;
created_at: string | null;
}
export const api = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
@ -454,6 +476,15 @@ export const api = {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }), body: JSON.stringify({ interval_minutes: intervalMinutes }),
}), }),
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
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 --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>
@ -478,6 +509,19 @@ export const api = {
addInvite: (email: string) => addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }), req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
// --- notification inbox (durable, server-backed) ---
notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> =>
req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
notificationUnreadCount: (): Promise<{ count: number }> =>
req("/api/me/notifications/unread_count"),
markNotificationRead: (id: number) =>
req(`/api/me/notifications/${id}/read`, { method: "POST" }),
markAllNotificationsRead: () =>
req("/api/me/notifications/read_all", { method: "POST" }),
dismissNotification: (id: number) =>
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
// --- user tags --- // --- user tags ---
createTag: (t: { name: string; color?: string; category?: string }) => createTag: (t: { name: string; color?: string; category?: string }) =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }), req("/api/tags", { method: "POST", body: JSON.stringify(t) }),

View file

@ -218,6 +218,21 @@ export function clearAll(): void {
emit(); 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 getActiveToasts = (): Notification[] => cachedActive;
export const getNotifications = (): Notification[] => cachedReversed; export const getNotifications = (): Notification[] => cachedReversed;
export const getUnreadCount = (): number => cachedUnread; export const getUnreadCount = (): number => cachedUnread;

View file

@ -14,6 +14,17 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.9.0",
date: "2026-06-18",
summary: "A notifications inbox, and automatic clean-up of videos that can no longer be played.",
features: [
"New Notifications center in the left sidebar, with an unread badge: one place for both durable system updates (about your library and background jobs) and your recent activity (with quick Unhide / Unwatch / Find-in-feed actions). Mark items read, dismiss them individually, or clear everything. Pop-up toasts still appear as before.",
"Automatic library maintenance: a background job spots videos that can no longer be played anywhere — deleted, made private, or premieres that never went live — hides them from your feed right away, and removes them after a grace period. Genuine 24/7 livestreams are left alone.",
"If a video you'd saved or added to a playlist gets removed this way, you get a single summary notification listing them — never one message per video.",
"Admin Scheduler: run any background job on demand with a “Run now” button (or “Start all now”), watch a live progress bar while long jobs run, and get an inbox notification with the result when a job you started finishes.",
],
},
{ {
version: "0.8.0", version: "0.8.0",
date: "2026-06-18", date: "2026-06-18",

View file

@ -78,17 +78,29 @@ export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k)); return KEYS.some((k) => params.has(k));
} }
export type Page = "feed" | "channels" | "stats" | "playlists" | "settings" | "scheduler"; // The single source of truth for valid page ids; `Page` and the runtime validator both
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback.
export const PAGES = [
"feed",
"channels",
"stats",
"playlists",
"settings",
"scheduler",
"notifications",
] as const;
export type Page = (typeof PAGES)[number];
/** Narrow an arbitrary string to a known Page (else null). */
export function isPage(p: string | null | undefined): p is Page {
return !!p && (PAGES as readonly string[]).includes(p);
}
export function readPage(): Page { export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page"); const p = new URLSearchParams(window.location.search).get("page");
return p === "channels" || return isPage(p) ? p : "feed";
p === "stats" ||
p === "playlists" ||
p === "settings" ||
p === "scheduler"
? p
: "feed";
} }
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the /** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the