diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index f1bcdc9..921c843 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -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".*?", re.DOTALL) @@ -24,6 +25,50 @@ def _tag(prop: str, content: str, attr: str = "property") -> str: return f'' +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: diff --git a/backend/tests/test_og.py b/backend/tests/test_og.py new file mode 100644 index 0000000..9e0cd6b --- /dev/null +++ b/backend/tests/test_og.py @@ -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