siftlode/backend/app/downloads/og.py
npeter83 cb170dfd32 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.
2026-07-07 22:28:49 +02:00

90 lines
4 KiB
Python

"""Server-rendered Open Graph tags for the public /watch/{token} page.
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
This injects per-video OG/Twitter tags into the served HTML so a shared link unfurls with the
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
"""
import html
import re
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import links as linksmod
from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
def _tag(prop: str, content: str, attr: str = "property") -> str:
return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />'
def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute(
select(DownloadLink).where(DownloadLink.token == token)
).scalar_one_or_none()
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected video"),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
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
)
tags = [
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
]
if channel:
tags.append(_tag("twitter:description", channel, attr="name"))
if image:
# A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image))
tags.append(_tag("twitter:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name"))
else:
tags.append(_tag("twitter:card", "summary", attr="name"))
return "\n ".join(tags)
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
"""Return index.html with the OG block replaced for this watch token, or None to serve the
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
tags = _watch_tags(token, db)
if tags is None:
return None
text = index_html.read_text(encoding="utf-8")
if "<!--OG:START-->" not in text:
return None
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
return _OG_BLOCK.sub(lambda _m: tags, text, count=1)