Merge fix/live-reenrich: re-enrich transient live videos until they settle

This commit is contained in:
npeter83 2026-06-16 10:06:58 +02:00
commit 94e031aa02
2 changed files with 52 additions and 1 deletions

View file

@ -21,6 +21,7 @@ from app.sync.videos import (
enrich_pending, enrich_pending,
poll_rss_channel, poll_rss_channel,
reconcile_full_history, reconcile_full_history,
refresh_live,
run_shorts_classification, run_shorts_classification,
) )
from app.youtube.client import YouTubeClient 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 total += n
if n == 0: if n == 0:
break 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 return total

View file

@ -5,7 +5,7 @@ import re
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone 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.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -260,6 +260,52 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
return enriched 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: def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
"""Finalize Shorts classification: cheaply mark non-candidates, then probe the """Finalize Shorts classification: cheaply mark non-candidates, then probe the
youtube.com/shorts URL for short-enough, non-live, enriched videos.""" youtube.com/shorts URL for short-enough, non-live, enriched videos."""