From c6ceaea89940365f7391aea503a5fa1b8a218a82 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 3 Jul 2026 02:23:21 +0200 Subject: [PATCH] fix(downloads): show real title + thumbnail while queued/downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/downloads/service.py | 25 ++++++++++++++++++++++++- backend/app/worker.py | 28 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index a446cc2..ad2f6bd 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session 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: @@ -54,6 +54,27 @@ def get_or_create_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: 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) sig = formats.format_sig(spec) 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( user_id=user_id, diff --git a/backend/app/worker.py b/backend/app/worker.py index b3a81ee..f54b719 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -182,8 +182,24 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None: db.commit() -def _make_progress_hook(job_id: int): - state = {"last_db": 0.0, "last_check": 0.0} +def _fill_asset_meta_early(asset_id: int, info: dict) -> None: + """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: now = time.monotonic() @@ -192,6 +208,12 @@ def _make_progress_hook(job_id: int): state["last_check"] = now if _job_status(job_id) not in ("running", None): 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": return 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) 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) _set_job(job_id, phase="downloading", progress=0)