From b394540701d9bbf0c022c7b1df7038cec7749a8e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 10:06:58 +0200 Subject: [PATCH] fix(sync): re-enrich live videos so ended streams gain duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enrich_pending only touches enriched_at IS NULL, so a video first seen while live was stamped live + duration-null and never revisited — staying 'live' with no duration forever after the broadcast ended. Add refresh_live (run after each enrich pass) that re-fetches anything still live/upcoming, plus just-ended was_live videos that haven't got their duration yet, until they settle. Cheap: videos.list is 1 unit per 50 ids. --- backend/app/sync/runner.py | 5 ++++ backend/app/sync/videos.py | 48 +++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) 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."""