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:
parent
374ad4ddc4
commit
cb170dfd32
12 changed files with 251 additions and 8 deletions
|
|
@ -58,6 +58,13 @@ def _file_url(token: str, grant: str | None = None) -> str:
|
|||
return f"{base}?g={grant}" if grant else base
|
||||
|
||||
|
||||
def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
|
||||
if not asset.poster_path:
|
||||
return None
|
||||
base = f"/api/public/watch/{token}/poster.jpg"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
|
||||
|
||||
@router.get("/watch/{token}")
|
||||
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
"""Public metadata for the watch page. For a password link, returns only `needs_password`
|
||||
|
|
@ -68,7 +75,10 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
|||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/watch/{token}/unlock")
|
||||
|
|
@ -80,14 +90,22 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
|
|||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
job, asset = _ready_target(db, link)
|
||||
return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
if not verify_password((payload.get("password") or ""), link.password_hash):
|
||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token)
|
||||
return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token, grant))}
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
|
|
@ -111,3 +129,20 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
|||
disposition = "attachment" if link.allow_download else "inline"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
job, asset = _ready_target(db, link)
|
||||
if 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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue