Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-16 17:46:15 +02:00
commit c2bf5b025c
21 changed files with 989 additions and 60 deletions

View file

@ -1 +1 @@
0.5.1 0.6.0

View file

@ -0,0 +1,30 @@
"""scheduler job interval overrides
Revision ID: 0015_scheduler_settings
Revises: 0014_demo_account
Create Date: 2026-06-16
Adds scheduler_settings: a per-job run-interval override (minutes), editable from the admin
Scheduler dashboard. Absent row = use the env/config default, so this is purely additive.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0015_scheduler_settings"
down_revision: Union[str, None] = "0014_demo_account"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"scheduler_settings",
sa.Column("job_id", sa.String(length=40), primary_key=True),
sa.Column("interval_minutes", sa.Integer(), nullable=False),
)
def downgrade() -> None:
op.drop_table("scheduler_settings")

View file

@ -26,7 +26,19 @@ from starlette.middleware.sessions import SessionMiddleware
from app import auth from app import auth
from app.config import settings from app.config import settings
from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version from app.routes import (
admin,
channels,
feed,
health,
me,
playlists,
quota,
scheduler as scheduler_routes,
sync,
tags,
version,
)
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler
@ -68,6 +80,7 @@ app.include_router(me.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)
app.include_router(scheduler_routes.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)

View file

@ -325,6 +325,17 @@ class AppState(Base):
) )
class SchedulerSetting(Base):
"""Admin override for a scheduler job's run interval (minutes). One row per job id;
absent = use the env/config default. DB-backed so it's editable from the admin UI at
runtime without a redeploy (env stays a fallback)."""
__tablename__ = "scheduler_settings"
job_id: Mapped[str] = mapped_column(String(40), primary_key=True)
interval_minutes: Mapped[int] = mapped_column(Integer)
class Playlist(Base): class Playlist(Base):
"""A per-user named, ordered collection of videos from the shared catalog. """A per-user named, ordered collection of videos from the shared catalog.

View file

@ -0,0 +1,110 @@
"""Admin Scheduler dashboard: a live view of what the background scheduler is doing —
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app import quota, state
from app.config import settings
from app.db import get_db
from app.models import Channel, Subscription, User, Video
from app.routes.admin import admin_user
from app.scheduler import JOB_INTERVALS, MAX_INTERVAL, MIN_INTERVAL, apply_interval, scheduler_snapshot
from app.sync.runner import estimate_deep_backfill
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
@router.patch("/jobs/{job_id}")
def set_job_interval(
job_id: str,
payload: dict,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
to the live scheduler immediately."""
if job_id not in JOB_INTERVALS:
raise HTTPException(status_code=404, detail="Unknown job")
try:
minutes = int(payload.get("interval_minutes"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="interval_minutes must be a number")
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException(
status_code=400,
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
)
applied = apply_interval(db, job_id, minutes)
return {"id": job_id, "interval_minutes": applied}
@router.get("")
def get_scheduler(
_: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
snap = scheduler_snapshot()
# Work still queued (shared catalog, so these are instance-wide, not per-user).
deep_requested = (
select(Subscription.id)
.where(
Subscription.channel_id == Channel.id,
Subscription.deep_requested.is_(True),
)
.exists()
)
deep_channels = (
db.execute(
select(Channel).where(Channel.backfill_done.is_(False), deep_requested)
)
.scalars()
.all()
)
deep_eta = estimate_deep_backfill(db, deep_channels)
queue = {
"channels_recent_pending": db.scalar(
select(func.count()).select_from(Channel).where(Channel.recent_synced_at.is_(None))
)
or 0,
"channels_deep_pending": deep_eta["channels_pending"],
"deep_eta_seconds": deep_eta["eta_seconds"],
"videos_pending_enrich": db.scalar(
select(func.count()).select_from(Video).where(Video.enriched_at.is_(None))
)
or 0,
"videos_pending_shorts": db.scalar(
select(func.count())
.select_from(Video)
.where(Video.shorts_probed.is_(False), Video.enriched_at.is_not(None))
)
or 0,
"videos_live_refresh": db.scalar(
select(func.count())
.select_from(Video)
.where(
or_(
Video.live_status.in_(("live", "upcoming")),
and_(Video.live_status == "was_live", Video.duration_seconds.is_(None)),
)
)
)
or 0,
}
used = quota.units_used_today(db)
quota_info = {
"used_today": used,
"remaining_today": quota.remaining_today(db),
"daily_budget": settings.quota_daily_budget,
"backfill_reserve": settings.backfill_quota_reserve,
}
return {
**snap,
"paused": state.is_sync_paused(db),
"queue": queue,
"quota": quota_info,
}

View file

@ -1,12 +1,16 @@
"""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 logging import logging
import threading
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from app import quota from app import 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.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.playlists import sync_all_playlists from app.sync.playlists import sync_all_playlists
@ -23,22 +27,133 @@ logger = logging.getLogger("subfeed.scheduler")
_scheduler: BackgroundScheduler | None = None _scheduler: BackgroundScheduler | None = None
# Per-job run activity, kept in-memory for the admin Scheduler dashboard to read (the
# scheduler lives in the same single-worker process as the API, so a request can read it
# directly). Ephemeral by design — it reflects "what the scheduler is doing right now and
# how the last runs went" since this process started; durable history isn't the goal here.
_activity: dict[str, dict] = {}
_activity_lock = threading.Lock()
# 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.
JOB_INTERVALS: dict[str, int] = {
"rss_poll": settings.rss_poll_minutes,
"enrich": settings.enrich_interval_minutes,
"backfill": settings.backfill_interval_minutes,
"autotag": settings.autotag_interval_minutes,
"shorts": settings.shorts_probe_interval_minutes,
"subscriptions": settings.subscriptions_resync_minutes,
"playlist_sync": settings.playlist_sync_minutes,
}
# Sane bounds for an admin-set interval (minutes).
MIN_INTERVAL = 1
MAX_INTERVAL = 1440 # one day
def load_intervals(db) -> dict[str, int]:
"""Effective per-job intervals: the env/config defaults overlaid with any admin overrides
saved in scheduler_settings."""
iv = dict(JOB_INTERVALS)
for row in db.execute(select(SchedulerSetting)).scalars():
if row.job_id in iv and row.interval_minutes:
iv[row.job_id] = row.interval_minutes
return iv
def apply_interval(db, job_id: str, minutes: int) -> int:
"""Persist a job's interval override and reschedule the live job immediately (if the
scheduler runs in this process). Returns the applied value."""
if job_id not in JOB_INTERVALS:
raise KeyError(job_id)
minutes = max(MIN_INTERVAL, min(MAX_INTERVAL, int(minutes)))
row = db.get(SchedulerSetting, job_id)
if row is None:
db.add(SchedulerSetting(job_id=job_id, interval_minutes=minutes))
else:
row.interval_minutes = minutes
db.commit()
if _scheduler is not None and _scheduler.get_job(job_id) is not None:
_scheduler.reschedule_job(job_id, trigger="interval", minutes=minutes)
logger.info("rescheduled job %s to every %s min", job_id, minutes)
return minutes
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _record(name: str, **fields) -> None:
with _activity_lock:
_activity.setdefault(name, {}).update(fields)
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)
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(),
last_result="paused")
return return
result = fn(db) result = fn(db)
logger.info("job %s -> %s", name, result) logger.info("job %s -> %s", name, result)
except Exception: _record(name, running=False, status="ok", last_finished=_now_iso(),
last_result=_summarize(result))
except Exception as exc:
db.rollback() db.rollback()
logger.exception("job %s failed", name) logger.exception("job %s failed", name)
_record(name, running=False, status="error", last_finished=_now_iso(),
last_error=str(exc) or exc.__class__.__name__)
finally: finally:
db.close() db.close()
def _summarize(result) -> str | None:
"""Compact one-line rendering of a job's return value for the dashboard."""
if result is None:
return None
if isinstance(result, dict):
return ", ".join(f"{k}={v}" for k, v in result.items()) or None
return str(result)
def scheduler_snapshot() -> dict:
"""What the scheduler is doing, for the admin dashboard. `running_here` is False in
environments where the scheduler is disabled (e.g. local dev shares the prod-ish DB but
runs no scheduler), so the UI can say so while still showing DB-derived queue/quota."""
running_here = _scheduler is not None
next_runs: dict[str, str | None] = {}
live_intervals: dict[str, int] = {}
if _scheduler is not None:
for job in _scheduler.get_jobs():
nr = getattr(job, "next_run_time", None)
next_runs[job.id] = nr.astimezone(timezone.utc).isoformat() if nr else None
iv = getattr(job.trigger, "interval", None)
if iv is not None:
live_intervals[job.id] = round(iv.total_seconds() / 60)
with _activity_lock:
acts = {k: dict(v) for k, v in _activity.items()}
jobs = []
for job_id, default_interval in JOB_INTERVALS.items():
a = acts.get(job_id, {})
jobs.append(
{
"id": job_id,
"interval_minutes": live_intervals.get(job_id, default_interval),
"next_run": next_runs.get(job_id),
"running": a.get("running", False),
"status": a.get("status"),
"last_started": a.get("last_started"),
"last_finished": a.get("last_finished"),
"last_result": a.get("last_result"),
"last_error": a.get("last_error"),
}
)
return {"running_here": running_here, "enabled": settings.scheduler_enabled, "jobs": jobs}
def _rss_job() -> None: def _rss_job() -> None:
_job("rss_poll", lambda db: run_rss_poll(db)) _job("rss_poll", lambda db: run_rss_poll(db))
@ -90,43 +205,24 @@ 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:
return return
# Effective intervals = env defaults overlaid with any admin overrides from the DB.
db = SessionLocal()
try:
iv = load_intervals(db)
finally:
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")
scheduler.add_job( for job_id, fn in fns.items():
_rss_job, "interval", minutes=settings.rss_poll_minutes, id="rss_poll" scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id)
)
scheduler.add_job(
_enrich_job, "interval", minutes=settings.enrich_interval_minutes, id="enrich"
)
scheduler.add_job(
_backfill_job,
"interval",
minutes=settings.backfill_interval_minutes,
id="backfill",
)
scheduler.add_job(
_autotag_job,
"interval",
minutes=settings.autotag_interval_minutes,
id="autotag",
)
scheduler.add_job(
_shorts_job,
"interval",
minutes=settings.shorts_probe_interval_minutes,
id="shorts",
)
scheduler.add_job(
_subscriptions_job,
"interval",
minutes=settings.subscriptions_resync_minutes,
id="subscriptions",
)
scheduler.add_job(
_playlist_sync_job,
"interval",
minutes=settings.playlist_sync_minutes,
id="playlist_sync",
)
scheduler.start() scheduler.start()
_scheduler = scheduler _scheduler = scheduler
logger.info("scheduler started") logger.info("scheduler started")

View file

@ -1,22 +1,34 @@
# Local development against the central Postgres (an always-on host that also runs the scheduler). # Self-contained local development stack — Postgres + API + scheduler, all on this machine.
# #
# Runs only the webapp/API — no local database, no scheduler — pointed at the shared # Fully decoupled: its own local database (a Docker volume) and its own scheduler, so there
# DB on the server. You see the same live data the 24/7 backfill is filling, and you # is no shared DB and no migration coupling with any other instance. Edit code, rebuild, and
# never run a second scheduler against it. # everything (including the background sync) runs right here.
# #
# Setup: in .env set # docker compose -f docker-compose.localdev.yml up -d --build
# DATABASE_URL=postgresql+psycopg://subfeed:<password>@your-db-host:5432/subfeed
# (use the central DB's host/port and the real POSTGRES_PASSWORD), then:
# docker compose -f docker-compose.localdev.yml up --build
# #
# Browse at http://localhost:8080 — Google login works here because the OAuth redirect # Browse at http://localhost:8080 — Google login works because the OAuth redirect is
# is http://localhost:8080/auth/callback. # http://localhost:8080/auth/callback. The API runs `alembic upgrade head` on startup, so it
# # applies local migrations to THIS local DB only. Seed the catalog once with a pg_dump from
# Note: the API runs `alembic upgrade head` on startup, so launching this with newer # another instance if you want real data (see RUNBOOK).
# local migrations will apply them to the shared DB. For risky schema work, point
# DATABASE_URL at a throwaway local Postgres instead.
services: services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-subfeed}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-subfeed}
POSTGRES_DB: ${POSTGRES_DB:-subfeed}
volumes:
- pgdata:/var/lib/postgresql/data
security_opt:
- apparmor:unconfined
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-subfeed} -d ${POSTGRES_DB:-subfeed}"]
interval: 5s
timeout: 5s
retries: 12
restart: unless-stopped
api: api:
build: build:
context: . context: .
@ -27,11 +39,19 @@ services:
BUILD_DATE: ${BUILD_DATE:-} BUILD_DATE: ${BUILD_DATE:-}
env_file: .env env_file: .env
environment: environment:
# Exactly one scheduler may write to the shared DB; the server owns it. # Own local database (overrides whatever DATABASE_URL is in .env).
SCHEDULER_ENABLED: "false" DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-subfeed}:${POSTGRES_PASSWORD:-subfeed}@db:5432/${POSTGRES_DB:-subfeed}
# This instance owns its scheduler now (its own DB, so no double-write concern).
SCHEDULER_ENABLED: "true"
depends_on:
db:
condition: service_healthy
# Harmless on Docker Desktop; needed if ever run under Docker-in-LXC. # Harmless on Docker Desktop; needed if ever run under Docker-in-LXC.
security_opt: security_opt:
- apparmor:unconfined - apparmor:unconfined
ports: ports:
- "${APP_PORT:-8080}:8000" - "${APP_PORT:-8080}:8000"
restart: unless-stopped restart: unless-stopped
volumes:
pgdata:

View file

@ -27,6 +27,7 @@ import Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Channels, { type ChannelStatusFilter } from "./components/Channels";
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { shouldAutoOpenOnboarding } from "./lib/onboarding";
@ -62,7 +63,8 @@ function loadInitialPage(): Page {
stored === "channels" || stored === "channels" ||
stored === "stats" || stored === "stats" ||
stored === "playlists" || stored === "playlists" ||
stored === "settings" stored === "settings" ||
stored === "scheduler"
) )
return stored; return stored;
return "feed"; return "feed";
@ -271,6 +273,8 @@ export default function App() {
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "settings" ? ( ) : page === "settings" ? (

View file

@ -78,6 +78,8 @@ export default function Header({
? t("header.account.playlists") ? t("header.account.playlists")
: page === "settings" : page === "settings"
? t("settings.title") ? t("settings.title")
: page === "scheduler"
? t("header.scheduler")
: t("header.channelManager")} : t("header.channelManager")}
</div> </div>
)} )}

View file

@ -3,6 +3,7 @@ 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,
BarChart3, BarChart3,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -109,8 +110,10 @@ export default function NavSidebar({
{ 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") },
]; ];
if (me.role === "admin") if (me.role === "admin") {
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
}
const rowBase = const rowBase =
"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";

View file

@ -0,0 +1,366 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
Check,
Clock,
Database,
Gauge,
Pause,
Pencil,
Play,
RadioTower,
X,
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
const POLL_MS = 4000;
// Seconds until an ISO instant (negative = past).
function secsUntil(iso: string | null): number | null {
if (!iso) return null;
return Math.round((new Date(iso).getTime() - Date.now()) / 1000);
}
function fmtCountdown(s: number): string {
if (s <= 0) return "now";
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
const DOT_CLASS: Record<string, string> = {
running: "bg-accent animate-pulse",
ok: "bg-emerald-500",
error: "bg-red-500",
skipped: "bg-amber-500",
idle: "bg-border",
};
function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS {
if (job.running) return "running";
if (job.status === "ok") return "ok";
if (job.status === "error") return "error";
if (job.status === "skipped") return "skipped";
return "idle";
}
function StatusDot({ k, withTooltip = true }: { k: keyof typeof DOT_CLASS; withTooltip?: boolean }) {
const { t } = useTranslation();
const dot = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
}
function StatusLegend() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-[11px] text-muted mb-2">
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<StatusDot k={k} withTooltip={false} />
{t(`scheduler.dot.${k}`)}
</span>
))}
</div>
);
}
function JobRow({
job,
onSave,
saving,
}: {
job: SchedulerJob;
onSave: (minutes: number) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(String(job.interval_minutes));
const next = secsUntil(job.next_run);
useEffect(() => {
if (!editing) setVal(String(job.interval_minutes));
}, [job.interval_minutes, editing]);
function save() {
const m = parseInt(val, 10);
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
setEditing(false);
}
return (
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
<StatusDot k={statusKey(job)} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`)}>
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
{t(`scheduler.jobs.${job.id}`, job.id)}
</span>
</Tooltip>
{editing ? (
<span className="inline-flex items-center gap-1">
<input
type="number"
min={1}
max={1440}
value={val}
autoFocus
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") setEditing(false);
}}
className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent"
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<button onClick={save} disabled={saving} className="text-emerald-400 hover:opacity-80 disabled:opacity-50" aria-label={t("common.save")}>
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditing(false)} className="text-muted hover:text-fg" aria-label={t("common.cancel")}>
<X className="w-3.5 h-3.5" />
</button>
</span>
) : (
<Tooltip hint={t("scheduler.editInterval")}>
<button
onClick={() => setEditing(true)}
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
</button>
</Tooltip>
)}
</div>
<div className="text-[11px] text-muted truncate">
{job.running
? t("scheduler.runningNow")
: job.last_finished
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-red-400"> · {job.last_error}</span>
) : job.last_result ? (
<span> · {job.last_result}</span>
) : null}
</div>
</div>
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
{job.running ? (
<span className="text-accent">{t("scheduler.active")}</span>
) : next != null ? (
<>
<div className="uppercase tracking-wide text-[10px]">{t("scheduler.nextRun")}</div>
<div>{fmtCountdown(next)}</div>
</>
) : (
"—"
)}
</div>
</div>
);
}
function Stat({
icon: Icon,
label,
value,
hint,
sub,
}: {
icon: typeof Database;
label: string;
value: string | number;
hint?: string;
sub?: string;
}) {
return (
<div className="glass-card rounded-xl p-3">
<div className="flex items-center gap-2 text-muted text-xs">
<Icon className="w-4 h-4 shrink-0" />
<Tooltip hint={hint ?? ""}>
<span className={hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""}>
{label}
</span>
</Tooltip>
</div>
<div className="text-xl font-semibold mt-1 tabular-nums">{value}</div>
{sub && <div className="text-[11px] text-muted">{sub}</div>}
</div>
);
}
export default function Scheduler() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
intervalMs: POLL_MS,
});
const data = q.data;
// Tick once a second so the per-job countdowns move between polls.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
});
const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
});
if (q.isLoading && !data)
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
if (!data)
return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
const quotaPct = data.quota.daily_budget
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
: 0;
return (
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
{/* Status strip */}
<div className="glass rounded-2xl p-4 flex flex-wrap items-center gap-3">
<Activity className="w-5 h-5 text-accent shrink-0" />
<div className="min-w-0">
<div className="font-semibold">{t("scheduler.title")}</div>
<div className="text-xs text-muted flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1">
<span
className={`w-2 h-2 rounded-full ${
data.paused ? "bg-amber-500" : data.running_here ? "bg-emerald-500" : "bg-border"
}`}
/>
{data.paused
? t("scheduler.paused")
: data.running_here
? t("scheduler.running")
: t("scheduler.notHere")}
</span>
{q.isFetching && <span className="text-accent">· {t("scheduler.updating")}</span>}
</div>
</div>
<div className="flex-1" />
<Tooltip hint={t("scheduler.pauseHint")}>
<button
onClick={() => pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{data.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</button>
</Tooltip>
</div>
{!data.running_here && (
<div className="flex items-center gap-2 text-sm bg-amber-500/15 border border-amber-500/30 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
<span className="text-muted">{t("scheduler.notHereNote")}</span>
</div>
)}
{/* Jobs */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
<StatusLegend />
<div className="space-y-1.5">
{data.jobs.map((job) => (
<JobRow
key={job.id}
job={job}
saving={intervalMut.isPending}
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
/>
))}
</div>
</div>
{/* Queue */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.queueTitle")}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Stat
icon={Database}
label={t("scheduler.queue.recentPending")}
hint={t("scheduler.queue.recentPendingHint")}
value={data.queue.channels_recent_pending.toLocaleString()}
/>
<Stat
icon={Database}
label={t("scheduler.queue.deepPending")}
hint={t("scheduler.queue.deepPendingHint")}
value={data.queue.channels_deep_pending.toLocaleString()}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.enrichPending")}
hint={t("scheduler.queue.enrichPendingHint")}
value={data.queue.videos_pending_enrich.toLocaleString()}
/>
<Stat
icon={Clock}
label={t("scheduler.queue.shortsPending")}
hint={t("scheduler.queue.shortsPendingHint")}
value={data.queue.videos_pending_shorts.toLocaleString()}
/>
<Stat
icon={RadioTower}
label={t("scheduler.queue.liveRefresh")}
hint={t("scheduler.queue.liveRefreshHint")}
value={data.queue.videos_live_refresh.toLocaleString()}
/>
</div>
</div>
{/* Quota */}
<div>
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.quotaTitle")}</div>
<div className="glass-card rounded-xl p-4">
<div className="flex items-center gap-2 text-sm">
<Gauge className="w-4 h-4 text-muted shrink-0" />
<span>
{t("scheduler.quotaUsed", {
used: data.quota.used_today.toLocaleString(),
budget: data.quota.daily_budget.toLocaleString(),
})}
</span>
<span className="flex-1" />
<span className="text-muted text-xs">
{t("scheduler.quotaRemaining", {
count: data.quota.remaining_today,
formatted: data.quota.remaining_today.toLocaleString(),
})}
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
<div
className={`h-full rounded-full ${quotaPct > 90 ? "bg-red-500" : "bg-accent"}`}
style={{ width: `${quotaPct}%` }}
/>
</div>
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
</div>
</div>
</div>
);
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Deine Abos durchsuchen…", "searchPlaceholder": "Deine Abos durchsuchen…",
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer",
"scope": { "scope": {
"label": "Feed-Quelle", "label": "Feed-Quelle",
"my": "Meine", "my": "Meine",
@ -16,6 +17,7 @@
"channels": "Kanäle", "channels": "Kanäle",
"playlists": "Wiedergabelisten", "playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"scheduler": "Planer",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden", "signOut": "Abmelden",

View file

@ -0,0 +1,65 @@
{
"title": "Hintergrund-Planer",
"loading": "Planer wird geladen…",
"running": "Läuft",
"paused": "Pausiert",
"notHere": "Läuft in dieser Instanz nicht",
"notHereNote": "Der Planer läuft auf dem Server. Diese Instanz zeigt die gemeinsame Warteschlange und das Kontingent, aber die Live-Job-Aktivität erscheint nur dort, wo der Planer läuft.",
"updating": "wird aktualisiert…",
"pause": "Pausieren",
"resume": "Fortsetzen",
"pauseHint": "Die gesamte Hintergrund-Synchronisierung der App pausieren oder fortsetzen (für alle Nutzer).",
"toggleFailed": "Der Planer-Status konnte nicht geändert werden",
"jobsTitle": "Jobs",
"queueTitle": "Wartende Arbeit",
"quotaTitle": "Gemeinsames API-Kontingent",
"everyMin": "alle {{count}} Min",
"runningNow": "Läuft gerade…",
"lastRun": "Letzter Lauf {{time}}",
"neverRun": "In dieser Sitzung noch nicht gelaufen",
"active": "aktiv",
"nextRun": "nächster",
"quotaUsed": "{{used}} / {{budget}} Einheiten heute verbraucht",
"quotaRemaining": "{{formatted}} übrig",
"quotaNote": "Gemeinsames YouTube-Data-API-Budget; Reset um Mitternacht US-Pazifik. Backfill tritt zurück, um Spielraum für interaktive Nutzung zu lassen.",
"minutes": "Min",
"editInterval": "Klicken, um das Intervall zu ändern",
"intervalFailed": "Intervall konnte nicht geändert werden",
"dot": {
"running": "läuft gerade",
"ok": "letzter Lauf OK",
"idle": "untätig (in dieser Sitzung noch nicht gelaufen)",
"error": "letzter Lauf fehlgeschlagen",
"skipped": "übersprungen (Sync pausiert)"
},
"jobDesc": {
"rss_poll": "Prüft den RSS-Feed jedes Kanals auf neue Uploads (kostenlos, kein Kontingent). Fällt er aus, erscheinen neue Videos erst später, wenn ein Backfill sie erfasst — der Feed hinkt YouTube hinterher.",
"enrich": "Holt Videodetails (Dauer, Aufrufe, Live-/Shorts-Flags) und prüft Live-Videos erneut, bis sie enden. Fällt er aus, bleiben Videos ohne Dauer/Statistik und beendete Streams hängen als „live“ fest.",
"backfill": "Holt neue Uploads und dann den gesamten Verlauf vorgemerkter Kanäle, innerhalb des gemeinsamen Kontingents. Fällt er aus, werden ältere Videos und der volle Verlauf nicht ergänzt.",
"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.",
"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."
},
"jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)",
"enrich": "Anreicherung + Live-Aktualisierung",
"backfill": "Backfill (aktuell + ganze Historie)",
"autotag": "Automatisches Tagging",
"shorts": "Shorts-Klassifizierung",
"subscriptions": "Abo-Resync",
"playlist_sync": "YouTube-Wiedergabelisten-Sync"
},
"queue": {
"recentPending": "Zu synchronisierende Kanäle",
"recentPendingHint": "Kanäle, deren neueste Uploads noch nicht abgerufen wurden.",
"deepPending": "Ganze Historie ausstehend",
"deepPendingHint": "Für vollständigen Verlauf vorgemerkte Kanäle, die noch nicht fertig sind.",
"enrichPending": "Zu anreichernde Videos",
"enrichPendingHint": "Videos, die noch auf Details warten (Dauer, Statistiken, Live-/Short-Klassifizierung).",
"shortsPending": "Zu klassifizierende Shorts",
"shortsPendingHint": "Bereits angereicherte Videos, die noch auf die Shorts-Prüfung warten.",
"liveRefresh": "Live zu aktualisieren",
"liveRefreshHint": "Live-/bevorstehende Videos (und gerade beendete Streams ohne Dauer), bis sie sich stabilisieren."
}
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Search your subscriptions…", "searchPlaceholder": "Search your subscriptions…",
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler",
"scope": { "scope": {
"label": "Feed source", "label": "Feed source",
"my": "Mine", "my": "Mine",
@ -16,6 +17,7 @@
"channels": "Channels", "channels": "Channels",
"playlists": "Playlists", "playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",

View file

@ -0,0 +1,65 @@
{
"title": "Background scheduler",
"loading": "Loading scheduler…",
"running": "Running",
"paused": "Paused",
"notHere": "Not running in this instance",
"notHereNote": "The scheduler runs on the server. This instance shows the shared queue and quota, but live job activity appears only where the scheduler runs.",
"updating": "updating…",
"pause": "Pause",
"resume": "Resume",
"pauseHint": "Pause or resume all background sync for the whole app (every user).",
"toggleFailed": "Couldn't change the scheduler state",
"jobsTitle": "Jobs",
"queueTitle": "Work queued",
"quotaTitle": "Shared API quota",
"everyMin": "every {{count}} min",
"runningNow": "Running now…",
"lastRun": "Last run {{time}}",
"neverRun": "Hasn't run yet this session",
"active": "active",
"nextRun": "next",
"quotaUsed": "{{used}} / {{budget}} units used today",
"quotaRemaining": "{{formatted}} left",
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
"minutes": "min",
"editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval",
"dot": {
"running": "running now",
"ok": "last run OK",
"idle": "idle (not run yet this session)",
"error": "last run failed",
"skipped": "skipped (sync paused)"
},
"jobDesc": {
"rss_poll": "Checks each channel's RSS feed for new uploads (free, no quota). If it stops, new videos only appear later when a backfill pass catches them, so your feed lags behind YouTube.",
"enrich": "Fetches video details (duration, views, live/Shorts flags) and re-checks live videos until they end. If it stops, videos stay without a duration/stats and ended livestreams stay stuck as “live”.",
"backfill": "Pulls in recent uploads, then the full back-catalogue for opted-in channels, within the shared quota. If it stops, older videos and full history don't fill in.",
"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.",
"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."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
"enrich": "Enrichment + live refresh",
"backfill": "Backfill (recent + full history)",
"autotag": "Auto-tagging",
"shorts": "Shorts classification",
"subscriptions": "Subscription resync",
"playlist_sync": "YouTube playlist sync"
},
"queue": {
"recentPending": "Channels to sync",
"recentPendingHint": "Channels whose recent uploads haven't been fetched yet.",
"deepPending": "Full-history pending",
"deepPendingHint": "Channels opted into full-history backfill that aren't finished yet.",
"enrichPending": "Videos to enrich",
"enrichPendingHint": "Videos awaiting details (duration, stats, live/short classification).",
"shortsPending": "Shorts to classify",
"shortsPendingHint": "Enriched videos still awaiting the Shorts probe.",
"liveRefresh": "Live to refresh",
"liveRefreshHint": "Live/upcoming videos (and just-ended streams without a duration yet) re-checked until they settle."
}
}

View file

@ -3,6 +3,7 @@
"searchPlaceholder": "Keresés a feliratkozásaid között…", "searchPlaceholder": "Keresés a feliratkozásaid között…",
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
"scope": { "scope": {
"label": "Hírfolyam forrása", "label": "Hírfolyam forrása",
"my": "Sajátom", "my": "Sajátom",
@ -16,6 +17,7 @@
"channels": "Csatornák", "channels": "Csatornák",
"playlists": "Lejátszási listák", "playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",

View file

@ -0,0 +1,65 @@
{
"title": "Háttér-ütemező",
"loading": "Ütemező betöltése…",
"running": "Fut",
"paused": "Szüneteltetve",
"notHere": "Ebben a példányban nem fut",
"notHereNote": "Az ütemező a szerveren fut. Ez a példány a közös sort és kvótát mutatja, de az élő job-aktivitás csak ott látszik, ahol az ütemező fut.",
"updating": "frissítés…",
"pause": "Szüneteltetés",
"resume": "Folytatás",
"pauseHint": "A teljes app háttér-szinkronjának szüneteltetése vagy folytatása (minden felhasználóra).",
"toggleFailed": "Nem sikerült módosítani az ütemező állapotát",
"jobsTitle": "Feladatok",
"queueTitle": "Sorban álló munka",
"quotaTitle": "Közös API-kvóta",
"everyMin": "{{count}} percenként",
"runningNow": "Most fut…",
"lastRun": "Utolsó futás {{time}}",
"neverRun": "Ebben a munkamenetben még nem futott",
"active": "aktív",
"nextRun": "köv.",
"quotaUsed": "{{used}} / {{budget}} egység ma felhasználva",
"quotaRemaining": "{{formatted}} maradt",
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
"minutes": "perc",
"editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"dot": {
"running": "most fut",
"ok": "utolsó futás OK",
"idle": "tétlen (ebben a munkamenetben még nem futott)",
"error": "utolsó futás hibázott",
"skipped": "kihagyva (szinkron szüneteltetve)"
},
"jobDesc": {
"rss_poll": "Az egyes csatornák RSS-feedjét nézi új feltöltésekért (ingyenes, nem fogyaszt kvótát). Ha leáll, az új videók csak később jelennek meg, amikor egy backfill behúzza őket — így a hírfolyam lemarad a YouTube mögött.",
"enrich": "Lekéri a videók adatait (hossz, megtekintés, élő/Shorts jelzők), és frissíti az élő videókat, amíg véget nem érnek. Ha leáll, a videók hossz/statisztika nélkül maradnak, a véget ért adások pedig „élő” állapotban ragadnak.",
"backfill": "Behúzza a friss feltöltéseket, majd a kijelölt csatornák teljes előzményét, a közös kvótán belül. Ha leáll, a régebbi videók és a teljes előzmény nem töltődik fel.",
"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.",
"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."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
"enrich": "Adatdúsítás + élő frissítés",
"backfill": "Backfill (friss + teljes előzmény)",
"autotag": "Automatikus címkézés",
"shorts": "Shorts-besorolás",
"subscriptions": "Feliratkozások újraszinkronja",
"playlist_sync": "YouTube lejátszási listák szinkronja"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
"recentPendingHint": "Csatornák, amelyek friss feltöltéseit még nem kértük le.",
"deepPending": "Teljes előzmény függőben",
"deepPendingHint": "Teljes előzményre kijelölt csatornák, amelyek még nincsenek kész.",
"enrichPending": "Dúsításra váró videó",
"enrichPendingHint": "Videók, amelyek még adatokra várnak (hossz, statisztikák, live/short besorolás).",
"shortsPending": "Besorolásra váró Shorts",
"shortsPendingHint": "Már dúsított videók, amelyek még a Shorts-próbára várnak.",
"liveRefresh": "Frissítendő élő",
"liveRefreshHint": "Élő/közelgő videók (és a frissen véget ért, hossz nélküli adások), amíg le nem ülepednek."
}
}

View file

@ -303,6 +303,39 @@ export interface ManagedChannel {
backfill_done: boolean; backfill_done: boolean;
} }
export interface SchedulerJob {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
status: "ok" | "error" | "skipped" | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null;
last_error: string | null;
}
export interface SchedulerStatus {
running_here: boolean;
enabled: boolean;
paused: boolean;
jobs: SchedulerJob[];
queue: {
channels_recent_pending: number;
channels_deep_pending: number;
deep_eta_seconds: number;
videos_pending_enrich: number;
videos_pending_shorts: number;
videos_live_refresh: number;
};
quota: {
used_today: number;
remaining_today: number;
daily_budget: number;
backfill_reserve: number;
};
}
export interface Account { export interface Account {
id: number; id: number;
email: string; email: string;
@ -400,6 +433,12 @@ export const api = {
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
}),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.6.0",
date: "2026-06-16",
summary: "Admins get a live Background Scheduler dashboard.",
features: [
"New admin Scheduler page: a live, self-refreshing view of the background jobs — what's running, each job's last result and a countdown to its next run — plus the work still queued and the shared API quota. Each job has a tooltip explaining what it does, and its run frequency is editable inline.",
],
},
{ {
version: "0.5.1", version: "0.5.1",
date: "2026-06-16", date: "2026-06-16",

View file

@ -78,11 +78,15 @@ 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"; export type Page = "feed" | "channels" | "stats" | "playlists" | "settings" | "scheduler";
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" || p === "stats" || p === "playlists" || p === "settings" return p === "channels" ||
p === "stats" ||
p === "playlists" ||
p === "settings" ||
p === "scheduler"
? p ? p
: "feed"; : "feed";
} }

View file

@ -0,0 +1,22 @@
import { useQuery, type QueryKey } from "@tanstack/react-query";
// Reusable "live" polling query: a thin wrapper over react-query that refetches on an
// interval and — by leaving refetchIntervalInBackground at its default false — pauses while
// the tab is unfocused, so it doesn't poll a server nobody's watching. This is the shared
// live-progress mechanism: the Scheduler dashboard uses it now; the notification bell and the
// future yt-dlp job queue reuse it rather than each re-implementing polling.
export function useLiveQuery<T>(
key: QueryKey,
queryFn: () => Promise<T>,
opts: { intervalMs?: number; enabled?: boolean } = {}
) {
const { intervalMs = 4000, enabled = true } = opts;
return useQuery({
queryKey: key,
queryFn,
enabled,
refetchInterval: enabled ? intervalMs : false,
refetchIntervalInBackground: false,
staleTime: 0,
});
}