feat(downloads): clickable channels + poster fallback for thumbnail-less sources

Two visual gaps for non-catalog downloads:
- Channel link: YouTube already exposes channel_url; Facebook exposes none but a
  numeric uploader_id that resolves at facebook.com/<id>. `_uploader_url` derives
  it so the auto-detected channel renders as a real clickable link.
- Poster: a source with no thumbnail (e.g. a direct reddit HLS URL) showed a
  blank image box. The worker now cuts a representative frame with ffmpeg
  (`ensure_poster`) into the `<base>.jpg` sidecar and records `poster_path`
  (migration 0050). The card, the public watch page (<video poster> + og:image),
  and link previews fall back to it via new authed + public poster endpoints.

Adds `app.downloads.backfill` (one-off, re-run-safe) to fill uploader_url
(re-extract YouTube/Facebook metadata) and posters for pre-existing downloads.
This commit is contained in:
npeter83 2026-07-07 22:28:49 +02:00
parent 374ad4ddc4
commit cb170dfd32
12 changed files with 251 additions and 8 deletions

View file

@ -97,6 +97,10 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"source_kind": job.source_kind,
"source_ref": job.source_ref,
"source_url": _reference_url(job, asset),
# A locally-generated poster to fall back to when the source had no thumbnail.
"poster_url": f"/api/downloads/{job.id}/poster.jpg"
if (asset is not None and asset.poster_path)
else None,
"profile_id": job.profile_id,
"spec": job.profile_snapshot,
"display_name": job.display_name,
@ -588,6 +592,22 @@ def storyboard_meta(
}
@router.get("/{job_id}/poster.jpg")
def poster_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
):
"""The locally-generated poster frame for a download whose source had no thumbnail."""
job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No poster.")
return FileResponse(path, media_type="image/jpeg")
@router.get("/{job_id}/storyboard.jpg")
def storyboard_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)