fix(share): declare og:image dimensions so the FB card includes the image on first scrape

The FB Sharing Debugger confirmed the real cause of the missing desktop-Messenger preview: the
scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape ("The provided 'og:image'
properties are not yet available… specify the dimensions using 'og:image:width'/'og:image:height'"),
so a freshly shared /watch link unfurled title-only. Read the poster JPEG's real dimensions from its
SOF marker (Pillow isn't a dep; the poster aspect differs from the video's, so the stored video
width/height won't do) and emit og:image:width/height. Only runs on a crawler hit. Unit-tested.
This commit is contained in:
2026-07-28 01:54:14 +02:00
parent 40acf104a1
commit ae14aa1c6a
2 changed files with 116 additions and 2 deletions
+52 -2
View File
@@ -15,6 +15,7 @@ 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"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
@@ -24,6 +25,50 @@ def _tag(prop: str, content: str, attr: str = "property") -> str:
return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />'
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(
@@ -74,11 +119,16 @@ def _watch_tags(token: str, db: Session) -> str | None:
# A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image))
# Facebook's scraper is pickier than Apple's on-device unfurl: spell out that the image is a
# public HTTPS JPEG so it renders the card even when it won't fetch/sniff the bytes itself.
# public HTTPS JPEG, and declare its dimensions so the card includes the image on the very
# first (async) scrape instead of unfurling title-only — see _jpeg_size.
if image.startswith("https://"):
tags.append(_tag("og:image:secure_url", image))
if image.endswith("poster.jpg"):
if image.endswith("poster.jpg") and asset.poster_path:
tags.append(_tag("og:image:type", "image/jpeg"))
dims = _jpeg_size(storage.safe_abs_path(settings.download_root, asset.poster_path))
if dims:
tags.append(_tag("og:image:width", str(dims[0])))
tags.append(_tag("og:image:height", str(dims[1])))
tags.append(_tag("twitter:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name"))
else: