- 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
91 lines
3 KiB
Python
91 lines
3 KiB
Python
"""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")
|