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.config import settings
from app.downloads import links as linksmod from app.downloads import links as linksmod
from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL) _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)}" />' 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: 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.""" """Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute( 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. # A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image)) tags.append(_tag("og:image", image))
# Facebook's scraper is pickier than Apple's on-device unfurl: spell out that the image is a # 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://"): if image.startswith("https://"):
tags.append(_tag("og:image:secure_url", image)) 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")) 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:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name")) tags.append(_tag("twitter:image", image, attr="name"))
else: else:
+64
View File
@@ -0,0 +1,64 @@
"""JPEG dimension parsing for the /watch OG image tags (S2, item 6).
Facebook's scraper fetches og:image asynchronously on first scrape, so the card is image-less
unless og:image:width/height are declared — which needs the poster's real dimensions, read from the
JPEG header without an image library. This pins that parser."""
import struct
from app.downloads.og import _jpeg_size
def _jpeg(width: int, height: int, sof_marker: int = 0xC0) -> bytes:
"""A minimal but well-formed JPEG: SOI + a JFIF APP0 segment (which the parser must SKIP) + a
SOFn frame header carrying the dimensions + EOI. sof_marker=0xC0 baseline, 0xC2 progressive."""
soi = b"\xff\xd8"
# APP0 JFIF: length 0x0010 (16) = the 2 length bytes + 14 payload bytes.
app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
# SOFn: length 0x0011 (17) = 2 + precision(1) + height(2) + width(2) + 1 comp * 9? Payload here is
# precision + h + w + numComp(1) + 3 components * 3 bytes = 1+2+2+1+9 = 15, +2 length = 17.
sof = (
b"\xff"
+ bytes([sof_marker])
+ b"\x00\x11\x08"
+ struct.pack(">H", height)
+ struct.pack(">H", width)
+ b"\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01"
)
return soi + app0 + sof + b"\xff\xd9"
def test_reads_baseline_jpeg_dimensions(tmp_path):
p = tmp_path / "poster.jpg"
p.write_bytes(_jpeg(1280, 720))
assert _jpeg_size(p) == (1280, 720)
def test_reads_progressive_jpeg_dimensions(tmp_path):
# ffmpeg posters can be progressive (SOF2); the width/height live in the same place.
p = tmp_path / "poster.jpg"
p.write_bytes(_jpeg(640, 1138, sof_marker=0xC2))
assert _jpeg_size(p) == (640, 1138)
def test_skips_preceding_segments(tmp_path):
# The JFIF APP0 segment sits before the SOF; a naive scan that didn't skip it by length would
# misread. The helper builds that layout, so a correct result proves the skip.
p = tmp_path / "poster.jpg"
p.write_bytes(_jpeg(1920, 3413))
assert _jpeg_size(p) == (1920, 3413)
def test_non_jpeg_returns_none(tmp_path):
p = tmp_path / "not.jpg"
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
assert _jpeg_size(p) is None
def test_truncated_before_sof_returns_none(tmp_path):
p = tmp_path / "trunc.jpg"
p.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00") # SOI + start of APP0, then nothing
assert _jpeg_size(p) is None
def test_missing_file_returns_none(tmp_path):
assert _jpeg_size(tmp_path / "nope.jpg") is None