diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py index 64f4e04..2cdd79b 100644 --- a/backend/app/sync/runner.py +++ b/backend/app/sync/runner.py @@ -21,6 +21,7 @@ from app.sync.videos import ( enrich_pending, poll_rss_channel, reconcile_full_history, + refresh_live, run_shorts_classification, ) from app.youtube.client import YouTubeClient @@ -65,6 +66,10 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int: total += n if n == 0: break + # Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended + # stream gains its final duration + was_live status instead of staying "live" forever. + if quota.remaining_today(db) > floor: + total += refresh_live(db, yt) return total diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index e4672ed..c4f002d 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -5,7 +5,7 @@ import re from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone -from sqlalchemy import func, or_, select, update +from sqlalchemy import and_, func, or_, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -260,6 +260,52 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> return enriched +def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: + """Re-fetch videos in a transient live state so they pick up their final shape. + + `enrich_pending` is one-shot (it only touches enriched_at IS NULL), but a live broadcast + is not a final state: a stream ends and becomes a normal VOD — gaining a real duration + and flipping live_status to was_live — and an upcoming premiere eventually goes live then + ends. So we revisit anything still live/upcoming, plus a just-ended stream that hasn't got + its duration yet (YouTube can lag a bit after the broadcast), until it settles. Cheap: + videos.list is 1 unit per 50 ids, and the live/upcoming set is normally tiny.""" + limit = limit or settings.enrich_batch_size + videos = ( + db.execute( + select(Video) + .where( + or_( + Video.live_status.in_(("live", "upcoming")), + and_( + Video.live_status == "was_live", + Video.duration_seconds.is_(None), + ), + ) + ) + .limit(limit) + ) + .scalars() + .all() + ) + if not videos: + return 0 + items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])} + now = _now() + updated = 0 + for video in videos: + item = items.get(video.id) + if item is None: + # The broadcast vanished (deleted/private). Stop treating it as live so it + # doesn't get refreshed forever; it just becomes an ordinary (dead) row. + video.live_status = "none" + continue + apply_video_details(video, item) + video.enriched_at = now + updated += 1 + db.commit() + return updated + + def run_shorts_classification(db: Session, limit: int | None = None) -> dict: """Finalize Shorts classification: cheaply mark non-candidates, then probe the youtube.com/shorts URL for short-enough, non-live, enriched videos."""