fix(downloads): show real title + thumbnail while queued/downloading

A queued/downloading row previously showed the bare video id and no thumbnail because asset
metadata was only filled when the download completed. Now:
- service.populate_from_catalog fills title/uploader/thumbnail/date/duration at enqueue from
  our own Video/Channel catalog (0 network cost) — feed downloads look right instantly
- worker fills the same from yt-dlp's info_dict on the first progress event (covers ad-hoc
  URLs / catalog misses), best-effort, never fails a download

Verified: a paused feed download now shows its real title, channel, thumbnail and duration.
This commit is contained in:
npeter83 2026-07-03 02:23:21 +02:00
parent f61ca96b6c
commit c6ceaea899
2 changed files with 49 additions and 4 deletions

View file

@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.downloads import formats, quota from app.downloads import formats, quota
from app.models import DownloadJob, MediaAsset from app.models import Channel, DownloadJob, MediaAsset, Video
def source_url(source_kind: str, source_ref: str) -> str: def source_url(source_kind: str, source_ref: str) -> str:
@ -54,6 +54,27 @@ def get_or_create_asset(
return asset return asset
def populate_from_catalog(db: Session, asset: MediaAsset) -> bool:
"""Fill an asset's display metadata (title/uploader/thumbnail/date/duration) from our own
catalog when the source is a known YouTube video so a queued/downloading row shows the
real title + thumbnail immediately instead of the bare id. 0 network cost. Returns True if
it found a catalog match."""
if asset.source_kind != "youtube" or asset.title:
return False
video = db.get(Video, asset.source_ref)
if video is None:
return False
asset.title = video.title
asset.thumbnail_url = video.thumbnail_url
asset.duration_s = video.duration_seconds
if video.published_at:
asset.upload_date = video.published_at.date()
channel = db.get(Channel, video.channel_id)
if channel is not None:
asset.uploader = channel.title
return True
def _next_queue_pos(db: Session) -> int: def _next_queue_pos(db: Session) -> int:
return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1 return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1
@ -74,6 +95,8 @@ def enqueue(
spec = formats.normalize(spec) spec = formats.normalize(spec)
sig = formats.format_sig(spec) sig = formats.format_sig(spec)
asset = get_or_create_asset(db, source_kind, source_ref, sig) asset = get_or_create_asset(db, source_kind, source_ref, sig)
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
populate_from_catalog(db, asset)
job = DownloadJob( job = DownloadJob(
user_id=user_id, user_id=user_id,

View file

@ -182,8 +182,24 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
db.commit() db.commit()
def _make_progress_hook(job_id: int): def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
state = {"last_db": 0.0, "last_check": 0.0} """Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
(only if not already filled at enqueue from our catalog) so an ad-hoc URL's row shows a
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
if asset is None or asset.title:
return
asset.title = info.get("title")
asset.uploader = info.get("uploader") or info.get("channel")
asset.thumbnail_url = info.get("thumbnail")
asset.duration_s = int(info["duration"]) if info.get("duration") else None
asset.upload_date = _parse_upload_date(info.get("upload_date"))
db.commit()
def _make_progress_hook(job_id: int, asset_id: int):
state = {"last_db": 0.0, "last_check": 0.0, "meta_done": False}
def hook(d: dict) -> None: def hook(d: dict) -> None:
now = time.monotonic() now = time.monotonic()
@ -192,6 +208,12 @@ def _make_progress_hook(job_id: int):
state["last_check"] = now state["last_check"] = now
if _job_status(job_id) not in ("running", None): if _job_status(job_id) not in ("running", None):
raise _Aborted raise _Aborted
if not state["meta_done"] and d.get("info_dict"):
state["meta_done"] = True
try:
_fill_asset_meta_early(asset_id, d["info_dict"])
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
pass
if d.get("status") != "downloading": if d.get("status") != "downloading":
return return
if now - state["last_db"] < 1.0: if now - state["last_db"] < 1.0:
@ -230,7 +252,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
staging.mkdir(parents=True, exist_ok=True) staging.mkdir(parents=True, exist_ok=True)
outtmpl = str(staging / "%(id)s.%(ext)s") outtmpl = str(staging / "%(id)s.%(ext)s")
opts = formats.build_ydl_opts(spec, outtmpl, _make_progress_hook(job_id)) opts = formats.build_ydl_opts(spec, outtmpl, _make_progress_hook(job_id, asset_id))
url = service.source_url(source_kind, source_ref) url = service.source_url(source_kind, source_ref)
_set_job(job_id, phase="downloading", progress=0) _set_job(job_id, phase="downloading", progress=0)