diff --git a/VERSION b/VERSION index 4b9fcbe..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.6.0 diff --git a/backend/alembic/versions/0015_scheduler_settings.py b/backend/alembic/versions/0015_scheduler_settings.py new file mode 100644 index 0000000..5a7f590 --- /dev/null +++ b/backend/alembic/versions/0015_scheduler_settings.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 34fef9e..ca0b01d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,19 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth 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 @@ -68,6 +80,7 @@ app.include_router(me.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) +app.include_router(scheduler_routes.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index 55f694c..f99e8ec 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): """A per-user named, ordered collection of videos from the shared catalog. diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py new file mode 100644 index 0000000..11eeeea --- /dev/null +++ b/backend/app/routes/scheduler.py @@ -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, + } diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 8d6d0c3..656db73 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -1,12 +1,16 @@ """Background scheduler: free RSS detection, enrichment of new videos, and quota-aware recent-first / deep backfill. Runs inside the API process (single worker).""" import logging +import threading +from datetime import datetime, timezone from apscheduler.schedulers.background import BackgroundScheduler +from sqlalchemy import select from app import quota from app.config import settings from app.db import SessionLocal +from app.models import SchedulerSetting from app.state import is_sync_paused from app.sync.autotag import run_autotag_all from app.sync.playlists import sync_all_playlists @@ -23,22 +27,133 @@ logger = logging.getLogger("subfeed.scheduler") _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: db = SessionLocal() + _record(name, running=True, last_started=_now_iso(), last_error=None) try: if is_sync_paused(db): logger.info("job %s skipped (sync paused)", name) + _record(name, running=False, status="skipped", last_finished=_now_iso(), + last_result="paused") return result = fn(db) 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() 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: 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: _job("rss_poll", lambda db: run_rss_poll(db)) @@ -90,43 +205,24 @@ def start_scheduler() -> None: global _scheduler if not settings.scheduler_enabled or _scheduler is not None: 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.add_job( - _rss_job, "interval", minutes=settings.rss_poll_minutes, id="rss_poll" - ) - 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", - ) + for job_id, fn in fns.items(): + scheduler.add_job(fn, "interval", minutes=iv[job_id], id=job_id) scheduler.start() _scheduler = scheduler logger.info("scheduler started") diff --git a/docker-compose.localdev.yml b/docker-compose.localdev.yml index 1ca87f1..a9e1c28 100644 --- a/docker-compose.localdev.yml +++ b/docker-compose.localdev.yml @@ -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 -# DB on the server. You see the same live data the 24/7 backfill is filling, and you -# never run a second scheduler against it. +# Fully decoupled: its own local database (a Docker volume) and its own scheduler, so there +# is no shared DB and no migration coupling with any other instance. Edit code, rebuild, and +# everything (including the background sync) runs right here. # -# Setup: in .env set -# DATABASE_URL=postgresql+psycopg://subfeed:@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 +# docker compose -f docker-compose.localdev.yml up -d --build # -# Browse at http://localhost:8080 — Google login works here because the OAuth redirect -# is http://localhost:8080/auth/callback. -# -# Note: the API runs `alembic upgrade head` on startup, so launching this with newer -# local migrations will apply them to the shared DB. For risky schema work, point -# DATABASE_URL at a throwaway local Postgres instead. +# Browse at http://localhost:8080 — Google login works because the OAuth redirect is +# 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 +# another instance if you want real data (see RUNBOOK). 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: build: context: . @@ -27,11 +39,19 @@ services: BUILD_DATE: ${BUILD_DATE:-} env_file: .env environment: - # Exactly one scheduler may write to the shared DB; the server owns it. - SCHEDULER_ENABLED: "false" + # Own local database (overrides whatever DATABASE_URL is in .env). + 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. security_opt: - apparmor:unconfined ports: - "${APP_PORT:-8080}:8000" restart: unless-stopped + +volumes: + pgdata: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688a049..7501839 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; +import Scheduler from "./components/Scheduler"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; import { shouldAutoOpenOnboarding } from "./lib/onboarding"; @@ -62,7 +63,8 @@ function loadInitialPage(): Page { stored === "channels" || stored === "stats" || stored === "playlists" || - stored === "settings" + stored === "settings" || + stored === "scheduler" ) return stored; return "feed"; @@ -271,6 +273,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "settings" ? ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f5c3bdc..5afe782 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -78,7 +78,9 @@ export default function Header({ ? t("header.account.playlists") : page === "settings" ? t("settings.title") - : t("header.channelManager")} + : page === "scheduler" + ? t("header.scheduler") + : t("header.channelManager")} )} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 0d8c75b..cb6d989 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { + Activity, BarChart3, ChevronLeft, ChevronRight, @@ -109,8 +110,10 @@ export default function NavSidebar({ { page: "channels", icon: Tv, label: t("header.account.channels") }, { 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: "scheduler", icon: Activity, label: t("header.account.scheduler") }); + } const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx new file mode 100644 index 0000000..8ac9928 --- /dev/null +++ b/frontend/src/components/Scheduler.tsx @@ -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 = { + 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 = ; + return withTooltip ? {dot} : dot; +} + +function StatusLegend() { + const { t } = useTranslation(); + return ( +
+ {(["running", "ok", "idle", "error", "skipped"] as const).map((k) => ( + + + {t(`scheduler.dot.${k}`)} + + ))} +
+ ); +} + +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 ( +
+ +
+
+ + + {t(`scheduler.jobs.${job.id}`, job.id)} + + + {editing ? ( + + 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" + /> + {t("scheduler.minutes")} + + + + ) : ( + + + + )} +
+
+ {job.running + ? t("scheduler.runningNow") + : job.last_finished + ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) + : t("scheduler.neverRun")} + {job.last_error ? ( + · {job.last_error} + ) : job.last_result ? ( + · {job.last_result} + ) : null} +
+
+
+ {job.running ? ( + {t("scheduler.active")} + ) : next != null ? ( + <> +
{t("scheduler.nextRun")}
+
{fmtCountdown(next)}
+ + ) : ( + "—" + )} +
+
+ ); +} + +function Stat({ + icon: Icon, + label, + value, + hint, + sub, +}: { + icon: typeof Database; + label: string; + value: string | number; + hint?: string; + sub?: string; +}) { + return ( +
+
+ + + + {label} + + +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +export default function Scheduler() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useLiveQuery(["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
{t("scheduler.loading")}
; + if (!data) + return
{t("common.somethingWrong")}
; + + const quotaPct = data.quota.daily_budget + ? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100)) + : 0; + + return ( +
+ {/* Status strip */} +
+ +
+
{t("scheduler.title")}
+
+ + + {data.paused + ? t("scheduler.paused") + : data.running_here + ? t("scheduler.running") + : t("scheduler.notHere")} + + {q.isFetching && · {t("scheduler.updating")}} +
+
+
+ + + +
+ + {!data.running_here && ( +
+ + {t("scheduler.notHereNote")} +
+ )} + + {/* Jobs */} +
+
{t("scheduler.jobsTitle")}
+ +
+ {data.jobs.map((job) => ( + intervalMut.mutate({ jobId: job.id, minutes })} + /> + ))} +
+
+ + {/* Queue */} +
+
{t("scheduler.queueTitle")}
+
+ + + + + +
+
+ + {/* Quota */} +
+
{t("scheduler.quotaTitle")}
+
+
+ + + {t("scheduler.quotaUsed", { + used: data.quota.used_today.toLocaleString(), + budget: data.quota.daily_budget.toLocaleString(), + })} + + + + {t("scheduler.quotaRemaining", { + count: data.quota.remaining_today, + formatted: data.quota.remaining_today.toLocaleString(), + })} + +
+
+
90 ? "bg-red-500" : "bg-accent"}`} + style={{ width: `${quotaPct}%` }} + /> +
+
{t("scheduler.quotaNote")}
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index e220b88..6c24304 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Deine Abos durchsuchen…", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", + "scheduler": "Planer", "scope": { "label": "Feed-Quelle", "my": "Meine", @@ -16,6 +17,7 @@ "channels": "Kanäle", "playlists": "Wiedergabelisten", "stats": "Statistik", + "scheduler": "Planer", "settings": "Einstellungen", "about": "Über", "signOut": "Abmelden", diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json new file mode 100644 index 0000000..293c752 --- /dev/null +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -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." + } +} diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index e6f29dc..794deea 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Search your subscriptions…", "channelManager": "Channel manager", "usageStats": "Usage & stats", + "scheduler": "Scheduler", "scope": { "label": "Feed source", "my": "Mine", @@ -16,6 +17,7 @@ "channels": "Channels", "playlists": "Playlists", "stats": "Stats", + "scheduler": "Scheduler", "settings": "Settings", "about": "About", "signOut": "Sign out", diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json new file mode 100644 index 0000000..e0db313 --- /dev/null +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -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." + } +} diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 16f3380..0e8b17f 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -3,6 +3,7 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", + "scheduler": "Ütemező", "scope": { "label": "Hírfolyam forrása", "my": "Sajátom", @@ -16,6 +17,7 @@ "channels": "Csatornák", "playlists": "Lejátszási listák", "stats": "Statisztika", + "scheduler": "Ütemező", "settings": "Beállítások", "about": "Névjegy", "signOut": "Kijelentkezés", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json new file mode 100644 index 0000000..bfedaf9 --- /dev/null +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -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." + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 647102a..0615f19 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -303,6 +303,39 @@ export interface ManagedChannel { 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 { id: number; email: string; @@ -400,6 +433,12 @@ export const api = { // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), + schedulerStatus: (): Promise => 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 --- requestAccess: (email: string): Promise<{ status: string }> => diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index ae13403..edb8849 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface 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", date: "2026-06-16", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index a0ad9d2..ab2a38b 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -78,11 +78,15 @@ export function hasFilterParams(params: URLSearchParams): boolean { 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 { 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 : "feed"; } diff --git a/frontend/src/lib/useLiveQuery.ts b/frontend/src/lib/useLiveQuery.ts new file mode 100644 index 0000000..121045b --- /dev/null +++ b/frontend/src/lib/useLiveQuery.ts @@ -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( + key: QueryKey, + queryFn: () => Promise, + 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, + }); +}