Channel features: subscribers, new-videos badges, activity-driven sync

- Store subscriber count from YouTube statistics and show it on channel page
- Sync 50 videos per channel with playlistItems pagination support
- Show per-channel and per-category new-videos counters (2-day window)
- Replace hourly videos sync with activity trigger (2h idle) and
  incremental backfill (hard cap 200 per channel)
- Clicking the sidebar new-videos count filters the category feed to
  recent videos only (new_only)
- Update agent-team docs: deploy after green checks
This commit is contained in:
vrubelroman 2026-09-17 21:07:33 +00:00
parent fde9a439df
commit e10df8dcbd
27 changed files with 1420 additions and 107 deletions

View file

@ -0,0 +1,91 @@
"""Activity-triggered videos sync.
The hourly scheduled videos sync is gone; instead, every authenticated request
(require_session) calls maybe_trigger_videos_sync(). The check must stay cheap:
if the sync is not running and enough idle time has passed since the last
completed sync, a background thread is started which calls sync.sync_videos
with its own DB session.
"""
import json
import logging
import threading
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.db import SessionLocal
from app.services import sync
from app.services.state import get_setting
logger = logging.getLogger(__name__)
def is_videos_sync_due(finished_at_iso: str | None, now: datetime, idle_hours: int) -> bool:
"""Pure predicate: should an automatic videos sync start now?
True when the last completed sync's finished_at is missing/unparseable or
older than `idle_hours`."""
if not finished_at_iso:
return True
if not isinstance(finished_at_iso, str):
return True
try:
finished_at = datetime.fromisoformat(finished_at_iso.replace("Z", "+00:00"))
except (ValueError, TypeError):
return True
if finished_at.tzinfo is None:
finished_at = finished_at.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return now - finished_at >= timedelta(hours=idle_hours)
def _extract_finished_at(raw: str | None) -> str | None:
if not raw:
return None
try:
payload = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(payload, dict):
return None
return payload.get("finished_at")
def _run_videos_sync() -> None:
db = SessionLocal()
try:
sync.sync_videos(db)
except sync.SyncInProgress:
# Lost the race against a manual sync or another trigger: fine, the
# other run will record the status.
logger.info("Triggered videos sync skipped: already running")
except Exception:
logger.exception("Triggered videos sync failed")
finally:
db.close()
def maybe_trigger_videos_sync() -> None:
"""Cheap per-request check; never raises. Starts a background videos sync
when due, or does nothing when a sync is already running."""
try:
if sync.is_videos_sync_running():
return
db = SessionLocal()
try:
raw = get_setting(db, sync.VIDEOS_SYNC_STATUS_KEY)
finished_at = _extract_finished_at(raw)
if not is_videos_sync_due(finished_at, datetime.now(timezone.utc), settings.videos_sync_idle_hours):
return
finally:
db.close()
logger.info(
"Videos sync due (last finished: %s), starting in background thread", finished_at
)
threading.Thread(target=_run_videos_sync, daemon=True).start()
except Exception:
# Never break the request because of the trigger itself.
logger.exception("Failed to evaluate videos sync trigger")