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.
109 lines
4.6 KiB
Python
109 lines
4.6 KiB
Python
"""Public "share by link" helpers for the download center.
|
|
|
|
A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one
|
|
download's file on the login-free `/watch/{token}` page. Password-protected links additionally
|
|
require a short-lived signed **grant** on the file request — because a `<video src>` can't send a
|
|
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
|
so the raw password never rides in the media URL (or server logs).
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
from app.config import settings
|
|
from app.models import DownloadLink
|
|
|
|
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
|
|
|
|
|
def new_token() -> str:
|
|
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
|
|
|
|
|
def is_expired(link: DownloadLink) -> bool:
|
|
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
|
|
|
|
|
# --- signed grants (password-protected file access) ----------------------------------------
|
|
|
|
|
|
def _sign(msg: str) -> str:
|
|
return hmac.new(settings.secret_key.encode(), msg.encode(), hashlib.sha256).hexdigest()[:32]
|
|
|
|
|
|
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
|
|
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`. The
|
|
link's current `password_version` is folded into the signature, so rotating the password (which
|
|
bumps the version) invalidates every grant minted under the old password at once."""
|
|
exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL
|
|
return f"{exp}.{_sign(f'{token}.{exp}.{password_version}')}"
|
|
|
|
|
|
def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
|
"""Verify a grant against the link's CURRENT `password_version`; a version bump (password
|
|
rotation) fails the signature check even before the grant's TTL expires."""
|
|
if not grant or "." not in grant:
|
|
return False
|
|
exp_s, sig = grant.split(".", 1)
|
|
try:
|
|
exp = int(exp_s)
|
|
# silent-ok: an untrusted grant that does not parse is simply not a valid grant, and the
|
|
# honest answer to it — a password prompt — is the same as for one that expired.
|
|
except ValueError:
|
|
return False
|
|
if exp < datetime.now(timezone.utc).timestamp():
|
|
return False
|
|
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
|
|
|
|
|
|
# --- serialization -------------------------------------------------------------------------
|
|
|
|
|
|
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
|
"""What the file owner sees when managing their links."""
|
|
return {
|
|
"id": link.id,
|
|
"token": link.token,
|
|
"url": f"{base_url}/watch/{link.token}",
|
|
"allow_download": link.allow_download,
|
|
"has_password": link.password_hash is not None,
|
|
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
|
"view_count": link.view_count,
|
|
"created_at": link.created_at.isoformat() if link.created_at else None,
|
|
}
|
|
|
|
|
|
def public_meta(
|
|
link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None
|
|
) -> dict:
|
|
"""Metadata for the public watch page (only ever returned once access is authorized).
|
|
|
|
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
|
|
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
|
|
from app.downloads import mediakind, service
|
|
|
|
source_url = service.reference_url(job, asset)
|
|
# Audio-only assets (mp3 rips, extracted audio) can't play inside a <video> element on WebKit
|
|
# (all iOS browsers), so the watch page renders a native <audio> instead. A generic uploaded
|
|
# file (media_kind='file') has no player at all — the watch page shows file info + a download.
|
|
# Both branches read the stored media_kind (the single source), not a re-derived codec check.
|
|
return {
|
|
"title": (job and job.display_name) or asset.title,
|
|
"uploader": (job and job.display_uploader) or asset.uploader,
|
|
"uploader_url": (job and job.display_uploader_url) or asset.uploader_url,
|
|
"source_url": source_url,
|
|
"extra_links": (job and job.extra_links) or [],
|
|
"duration_s": asset.duration_s,
|
|
"width": asset.width,
|
|
"height": asset.height,
|
|
"audio": asset.media_kind == mediakind.AUDIO,
|
|
"media_kind": asset.media_kind,
|
|
"size_bytes": asset.size_bytes,
|
|
"container": asset.container,
|
|
"allow_download": link.allow_download,
|
|
"file_url": file_url,
|
|
"poster_url": poster_url,
|
|
}
|