114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
161 lines
7.9 KiB
Python
161 lines
7.9 KiB
Python
"""Server-rendered Open Graph tags for the public /watch/{token} page.
|
||
|
||
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
|
||
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
|
||
This injects per-video OG/Twitter tags into the served HTML so a shared link unfurls with the
|
||
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
|
||
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
|
||
"""
|
||
|
||
import html
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import select
|
||
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)
|
||
|
||
|
||
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
|
||
# silent-ok: None omits width/height from the unfurl card, which is what the card does for
|
||
# every poster whose dimensions were never asked for. Nothing is claimed that is not true.
|
||
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(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||
if link is None or linksmod.is_expired(link):
|
||
return None
|
||
url = f"{settings.app_base}/watch/{token}"
|
||
if link.password_hash:
|
||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||
return "\n ".join(
|
||
[
|
||
"<title>Siftlode</title>",
|
||
_tag("og:title", "Password-protected video"),
|
||
_tag("og:description", "Shared via Siftlode"),
|
||
_tag("og:type", "video.other"),
|
||
_tag("og:url", url),
|
||
_tag("og:site_name", "Siftlode"),
|
||
]
|
||
)
|
||
job = db.get(DownloadJob, link.job_id)
|
||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||
if job is None or asset is None or asset.status != "ready":
|
||
return None
|
||
title = (job.display_name or asset.title or "Video").strip()
|
||
channel = (job.display_uploader or asset.uploader or "").strip()
|
||
# 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"<title>{html.escape(title)}</title>",
|
||
_tag("og:title", title),
|
||
_tag("og:description", channel or "Shared via Siftlode"),
|
||
_tag("og:type", "video.other"),
|
||
_tag("og:url", url),
|
||
_tag("og:site_name", "Siftlode"),
|
||
_tag("twitter:title", title, attr="name"),
|
||
]
|
||
if channel:
|
||
tags.append(_tag("twitter:description", channel, attr="name"))
|
||
if image:
|
||
# A real thumbnail → a large image card; without one, a plain (text) card is served.
|
||
tags.append(_tag("og:image", image))
|
||
# 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))
|
||
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)
|
||
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:
|
||
tags.append(_tag("twitter:card", "summary", attr="name"))
|
||
return "\n ".join(tags)
|
||
|
||
|
||
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
|
||
"""Return index.html with the OG block replaced for this watch token, or None to serve the
|
||
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
|
||
tags = _watch_tags(token, db)
|
||
if tags is None:
|
||
return None
|
||
text = index_html.read_text(encoding="utf-8")
|
||
if "<!--OG:START-->" not in text:
|
||
return None
|
||
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
|
||
return _OG_BLOCK.sub(lambda _m: tags, text, count=1)
|