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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user