A shared /watch/{token} link is a client-rendered SPA, so a social crawler only
saw the generic index.html — a blank link card. The server now injects per-video
Open Graph / Twitter tags (title, channel, thumbnail) into the served HTML for
that route, so links unfurl richly in Messenger and other chat apps; real
browsers ignore the extra tags and hydrate the page as usual. Password /
expired / invalid links fall back to the generic card with no metadata leak.
Also shortens the generic site description used for search engines and link
previews.
85 lines
3.7 KiB
Python
85 lines
3.7 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()
|
|
image = asset.thumbnail_url # already an absolute (youtube/fb) URL, or 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)
|