feat(scheduler): admin run-now/run-all triggers + live progress + completion notices

Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler
dashboard (admin-gated endpoints). Triggers run the job in a background thread
independent of its interval, refusing a concurrent run (409). While running, the
long jobs (maintenance, enrich, backfill, shorts) report live progress through a
decoupled contextvar sink, shown as a progress bar on the job row via the existing
4s poll. A manually-triggered run posts a completion notification to the triggering
admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the
"scheduler" type trilingually from type+data. While here, give the maintenance job
its missing dashboard label/description in all three languages.
This commit is contained in:
npeter83 2026-06-18 04:01:10 +02:00
parent 3a0789ebe7
commit ed4194a8d3
15 changed files with 344 additions and 30 deletions

View file

@ -25,7 +25,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import quota
from app import progress, quota
from app.config import settings
from app.models import Playlist, PlaylistItem, Video, VideoState
from app.notifications import create_notification
@ -147,6 +147,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
)
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
@ -221,6 +222,7 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
flagged_new += 1
db.commit()
checked += len(page)
progress.report(checked, batch, "revalidate")
return {"checked": checked, "flagged": flagged_new}

View file

@ -9,7 +9,7 @@ import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import quota
from app import progress, quota
from app.config import settings
log = logging.getLogger("subfeed.sync")
@ -64,6 +64,7 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
break
n = enrich_pending(db, yt)
total += n
progress.report(total, None, "enrich") # total unknown (drains until empty)
if n == 0:
break
# Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
@ -121,6 +122,7 @@ def run_recent_backfill(
try:
videos_added += backfill_channel_recent(db, yt, channel)
processed += 1
progress.report(processed, len(channels), "backfill_recent")
except Exception:
db.rollback()
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:
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
processed += 1
progress.report(processed, len(channels), "backfill_deep")
except Exception:
db.rollback()
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.orm import Session
from app import progress
from app.config import settings
from app.models import Channel, Video
from app.youtube.client import YouTubeClient, best_thumbnail
@ -365,5 +366,6 @@ def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
probed += 1
if result:
shorts += 1
progress.report(probed, len(candidates), "probe")
db.commit()
return {"probed": probed, "shorts": shorts}