From 9187ae56b1521c5dbc5296519ce30d7d7ab19a42 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 03:20:31 +0200 Subject: [PATCH] fix(share): use the stable YouTube hqdefault thumbnail for OG on YT shares (0.57.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facebook's scraper rejected our self-hosted poster as "Corrupted Image": the poster is an ffmpeg-produced JPEG (non-standard marker order) that browsers/iOS/Twitter decode fine but FB's stricter processor won't. For a YouTube source, point og:image at the video's stable, non-expiring `i.ytimg.com/vi//hqdefault.jpg` (a Google-encoded JPEG FB decodes reliably) built from the id in the stored thumbnail — instead of the stored `sqp`-signed variant, which can expire. Non-YouTube sources keep the poster fallback (fine everywhere but FB). Declares 480×360 so FB includes it on the first async scrape. og.py only — no schema change. --- VERSION | 2 +- backend/app/downloads/og.py | 41 +++++++++++++++++++------------- frontend/src/lib/releaseNotes.ts | 7 ++++++ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index 78756de..68e76af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.57.0 \ No newline at end of file +0.57.1 \ No newline at end of file diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index 23331fd..09f13f1 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -95,15 +95,22 @@ def _watch_tags(token: str, db: Session) -> str | None: return None title = (job.display_name or asset.title or "Video").strip() channel = (job.display_uploader or asset.uploader or "").strip() - # Prefer our self-hosted poster for the link-preview image: a remote thumbnail (Facebook's - # signed CDN URL especially) expires and isn't reliably fetchable cross-origin by the crawler. - # (This block only runs for non-password links, so the poster needs no grant.) Fall back to the - # remote thumbnail only if we somehow have no poster. - image = ( - f"{settings.app_base}/api/public/watch/{token}/poster.jpg" - if asset.poster_path - else asset.thumbnail_url - ) + # Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the + # Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's + # decoder won't accept, even though browsers/iOS/Twitter render it fine. So for a YouTube source + # prefer its STABLE hqdefault thumbnail — a Google-encoded JPEG (480×360) at a non-expiring URL + # (unlike the stored `sqp`-signed variant) that FB decodes reliably. Non-YouTube sources fall back + # to the poster (fine everywhere but FB) then the raw remote thumbnail. This block only runs for + # non-password links, so the poster needs no grant. + yt_id_match = re.search(r"i\.ytimg\.com/vi/([\w-]+)/", asset.thumbnail_url or "") + image_dims: tuple[int, int] | None = None + if yt_id_match: + image: str | None = f"https://i.ytimg.com/vi/{yt_id_match.group(1)}/hqdefault.jpg" + image_dims = (480, 360) + elif asset.poster_path: + image = f"{settings.app_base}/api/public/watch/{token}/poster.jpg" + else: + image = asset.thumbnail_url tags = [ f"{html.escape(title)}", _tag("og:title", title), @@ -118,20 +125,20 @@ def _watch_tags(token: str, db: Session) -> str | None: if image: # 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, and declare its dimensions so the card includes the image on the very - # first (async) scrape instead of unfurling title-only — see _jpeg_size. + # Declare the image is a public HTTPS JPEG with known dimensions, so FB (which fetches + # og:image asynchronously on first scrape) includes it instead of unfurling title-only. For a + # poster we read the real dimensions from its JPEG header; the ytimg hqdefault is a fixed 480×360. if image.startswith("https://"): tags.append(_tag("og:image:secure_url", image)) - if image.endswith("poster.jpg") and asset.poster_path: tags.append(_tag("og:image:type", "image/jpeg")) + if image_dims is None and image.endswith("poster.jpg") and asset.poster_path: # safe_abs_path returns None if the poster file is missing (gc'd) or escapes the root — # guard it, else _jpeg_size(None) would raise an uncaught AttributeError on this public page. poster_abs = storage.safe_abs_path(settings.download_root, asset.poster_path) - dims = _jpeg_size(poster_abs) if poster_abs else None - if dims: - tags.append(_tag("og:image:width", str(dims[0]))) - tags.append(_tag("og:image:height", str(dims[1]))) + image_dims = _jpeg_size(poster_abs) if poster_abs else None + if image_dims: + tags.append(_tag("og:image:width", str(image_dims[0]))) + tags.append(_tag("og:image:height", str(image_dims[1]))) tags.append(_tag("twitter:card", "summary_large_image", attr="name")) tags.append(_tag("twitter:image", image, attr="name")) else: diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 95609a4..710d97b 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,13 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.57.1", + date: "2026-07-28", + fixes: [ + "Sharing: a YouTube video's shared-link preview now uses the original YouTube thumbnail, which Facebook Messenger reliably shows — the previously-served self-hosted image was a valid picture everywhere else but Facebook's stricter processor rejected it as “corrupted”.", + ], + }, { version: "0.57.0", date: "2026-07-28",