fix(share): self-host og:image so link previews stay reliable

A remote thumbnail as og:image is unreliable for the crawler — Facebook's signed
CDN URL expires (so an old shared link's image later vanishes) and cross-origin
hotlinking is flaky. Record a self-hosted poster for EVERY download (ensure_poster
returns the existing <base>.jpg thumbnail sidecar, or cuts a frame) and point
og:image at our own /api/public/watch/{token}/poster.jpg. Backfill now fills
poster_path for all existing downloads, not just thumbnail-less ones.
This commit is contained in:
npeter83 2026-07-07 22:43:18 +02:00
parent 8b02b41059
commit b63b3334d6
4 changed files with 17 additions and 11 deletions

View file

@ -78,7 +78,9 @@ def run() -> dict:
a.uploader_url = uu
filled_url += 1
log.info("asset %s uploader_url <- %s", a.id, uu)
if a.thumbnail_url is None and a.poster_path is None:
if a.poster_path is None:
# Record a self-hosted poster for every download (the reliable og:image source):
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
rel = editmod.ensure_poster(a, settings.download_root)
if rel:
a.poster_path = rel

View file

@ -50,11 +50,14 @@ def _watch_tags(token: str, db: Session) -> str | None:
return None
title = (job.display_name or asset.title or "Video").strip()
channel = (job.display_uploader or asset.uploader or "").strip()
# Prefer the source thumbnail; fall back to our generated poster (absolute, token-gated URL) so
# even a thumbnail-less video (e.g. a reddit clip) unfurls with an image. (This block only runs
# for non-password links, so the poster needs no grant.)
image = asset.thumbnail_url or (
f"{settings.app_base}/api/public/watch/{token}/poster.jpg" if asset.poster_path else None
# Prefer our self-hosted poster for the link-preview image: a remote thumbnail (Facebook's
# signed CDN URL especially) expires and isn't reliably fetchable cross-origin by the crawler.
# (This block only runs for non-password links, so the poster needs no grant.) Fall back to the
# remote thumbnail only if we somehow have no poster.
image = (
f"{settings.app_base}/api/public/watch/{token}/poster.jpg"
if asset.poster_path
else asset.thumbnail_url
)
tags = [
f"<title>{html.escape(title)}</title>",

View file

@ -428,9 +428,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
asset.uploader_url = _uploader_url(info)
asset.upload_date = meta.upload_date
asset.thumbnail_url = meta.thumbnail_url
# No thumbnail from the source (e.g. a direct media URL) → cut a poster frame from
# the file so the card / watch page / link preview still shows an image.
if not meta.thumbnail_url:
# Record a self-hosted poster for every download: it's the reliable link-preview
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
asset.source_webpage_url = info.get("webpage_url")
asset.nfo_written = nfo_ok

View file

@ -20,7 +20,7 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
summary: "Clickable channels, poster images for any source, and a dialog-dismiss fix.",
features: [
"Downloads: the auto-detected channel under a download is now a clickable link to the real channel for YouTube and Facebook sources. Your existing downloads were backfilled, so they're clickable too.",
"Downloads: a video from a source with no thumbnail (e.g. a direct Reddit video link) now gets a poster image generated from the video itself — shown on the card, on the shared watch page, and in rich link previews. Existing thumbnail-less downloads were backfilled.",
"Downloads: a video from a source with no thumbnail (e.g. a direct Reddit video link) now gets a poster image generated from the video itself — shown on the card, on the shared watch page, and in rich link previews. Shared-link previews now use this self-hosted image, so they render reliably in chat apps and don't break over time. Existing downloads were backfilled.",
],
fixes: [
"Dialogs no longer close by accident when you press the mouse inside (e.g. selecting text in a field), drag outside, and release — a popup now only closes when the click both starts and ends on the backdrop.",