diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py
new file mode 100644
index 0000000..60a9590
--- /dev/null
+++ b/backend/app/downloads/og.py
@@ -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".*?", re.DOTALL)
+
+
+def _tag(prop: str, content: str, attr: str = "property") -> str:
+ return f''
+
+
+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()
+ image = asset.thumbnail_url # already an absolute (youtube/fb) URL, or None
+ 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))
+ 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)
diff --git a/backend/app/main.py b/backend/app/main.py
index bea87ee..772888a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -20,7 +20,7 @@ if not _siftlode_logger.handlers:
log = logging.getLogger("siftlode.app")
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 starlette.middleware.sessions import SessionMiddleware
@@ -178,6 +178,21 @@ async def index() -> FileResponse:
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}")
async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above.
diff --git a/frontend/index.html b/frontend/index.html
index c7ed055..39d73cb 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,17 +4,21 @@
+
+
Siftlode
+