feat(share): rich link previews (Open Graph) for /watch pages

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.
This commit is contained in:
npeter83 2026-07-07 20:19:30 +02:00
parent 8c86c6b4a8
commit 8591e45747
3 changed files with 107 additions and 3 deletions

View file

@ -0,0 +1,85 @@
"""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)

View file

@ -20,7 +20,7 @@ if not _siftlode_logger.handlers:
log = logging.getLogger("siftlode.app") log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@ -178,6 +178,21 @@ async def index() -> FileResponse:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
back to the generic card."""
from app.downloads import og
with SessionLocal() as db:
rendered = og.render_watch_html(token, INDEX_HTML, db)
if rendered is None:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse: async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above. # Client-side routes fall back to index.html; real API/asset paths are matched above.

View file

@ -4,17 +4,21 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
<!--OG:START-->
<title>Siftlode</title> <title>Siftlode</title>
<meta <meta
name="description" name="description"
content="Siftlode is a self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search your feed, build playlists that sync both ways, and watch in a clean, ad-free interface." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:title" content="Siftlode" /> <meta property="og:title" content="Siftlode" />
<meta <meta
property="og:description" property="og:description"
content="A self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search, build two-way-syncing playlists, and watch ad-free." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<!--OG:END-->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>