"""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.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r".*?", re.DOTALL)
def _tag(prop: str, content: str, attr: str = "property") -> str:
return f''
def _jpeg_size(path: Path) -> tuple[int, int] | None:
"""Read (width, height) from a JPEG's SOF marker without an image library (Pillow isn't a dep).
Facebook's scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape, so a card built then
has NO image unless og:image:width/height are declared up front — which is exactly why a freshly
shared /watch link unfurled title-only on desktop Messenger. We can't declare dimensions we don't
know, and the poster's aspect differs from the video's, so read them straight from the JPEG here
(cheap — only on a crawler hit). Returns None for a non-JPEG/truncated file, in which case the
dimensions are simply omitted (the prior behaviour)."""
try:
with path.open("rb") as f:
if f.read(2) != b"\xff\xd8": # SOI — not a JPEG
return None
while True:
b = f.read(1)
if not b:
return None
if b != b"\xff":
continue
marker = f.read(1)
while marker == b"\xff": # skip fill bytes
marker = f.read(1)
if not marker:
return None
m = marker[0]
if m == 0x01 or 0xD0 <= m <= 0xD9: # standalone markers, no length payload
continue
seg = f.read(2)
if len(seg) < 2:
return None
length = (seg[0] << 8) + seg[1]
# SOF0..SOF15 carry the frame dimensions (skip DHT/DAC/RST, which share the range).
if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
data = f.read(5)
if len(data) < 5:
return None
height = (data[1] << 8) + data[2]
width = (data[3] << 8) + data[4]
return width, height
f.seek(length - 2, 1) # skip this segment's payload
except OSError:
return None
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(
[
"
Siftlode",
_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()
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
# decoder won't accept, even though browsers/iOS/Twitter render it fine. So for a YouTube source
# prefer its STABLE hqdefault thumbnail — a Google-encoded JPEG (480×360) at a non-expiring URL
# (unlike the stored `sqp`-signed variant) that FB decodes reliably. Non-YouTube sources fall back
# to the poster (fine everywhere but FB) then the raw remote thumbnail. This block only runs for
# non-password links, so the poster needs no grant.
yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "")
image_dims: tuple[int, int] | None = None
if yt_id_match:
image: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg"
image_dims = (480, 360)
elif asset.poster_path:
image = f"{settings.app_base}/api/public/watch/{token}/poster.jpg"
else:
image = asset.thumbnail_url
tags = [
f"{html.escape(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))
# Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches
# og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a
# poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360.
if image.startswith("https://"):
tags.append(_tag("og:image:secure_url", image))
tags.append(_tag("og:image:type", "image/jpeg"))
if image_dims is None and image.endswith("poster.jpg") and asset.poster_path:
# safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root —
# guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page.
poster_abs = storage.safe_abs_path(settings.download_root, asset.poster_path)
image_dims = _jpeg_size(poster_abs) if poster_abs else None
if image_dims:
tags.append(_tag("og:image:width", str(image_dims[0])))
tags.append(_tag("og:image:height", str(image_dims[1])))
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 "" 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)