Files
siftlode/backend/app/plex/paths.py
T
peter 6b0401b922 fix(logs): rule on every swallowed exception in the backend
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.
2026-09-18 01:20:42 +02:00

62 lines
2.7 KiB
Python

"""Map a Plex-side media path to Siftlode's local mount, so the player can open the physical file.
The Plex metadata gives each media Part an absolute path as the PLEX server sees it (e.g.
``/data/movies/Foo.mkv``). Siftlode plays the file directly, so it must translate that to the path
as SIFTLODE sees the same storage on a local mount (e.g. ``/plex-media/movies/Foo.mkv``). The map
is admin-configured (sysconfig ``plex_path_map``) as newline/comma-separated ``plexPrefix=localPrefix``
pairs; the longest matching Plex prefix wins. An empty map means the paths are identical on both
sides (Plex and Siftlode see the same filesystem).
"""
from pathlib import Path
from sqlalchemy.orm import Session
from app import logs, sysconfig
log = logs.get_logger(__name__)
def parse_map(raw: str) -> list[tuple[str, str]]:
"""Parse the ``plexPrefix=localPrefix`` pairs, longest Plex prefix first."""
pairs: list[tuple[str, str]] = []
for chunk in (raw or "").replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
plex, local = chunk.split("=", 1)
plex, local = plex.strip(), local.strip()
if plex and local:
pairs.append((plex, local))
pairs.sort(key=lambda p: len(p[0]), reverse=True)
return pairs
def map_path(db: Session, plex_path: str) -> str:
"""Translate a Plex-side absolute path to the local mount path (no mapping = unchanged)."""
for plex_prefix, local_prefix in parse_map(sysconfig.get_str(db, "plex_path_map")):
if plex_path == plex_prefix or plex_path.startswith(plex_prefix.rstrip("/") + "/"):
rest = plex_path[len(plex_prefix) :].lstrip("/")
return str(Path(local_prefix) / rest) if rest else local_prefix
return plex_path
def local_media_path(db: Session, plex_path: str | None) -> Path | None:
"""The resolved, existing local file for a Plex media path, or None if it can't be read.
The path originates from the trusted Plex server + an admin-set map (not user input), so no
traversal check is needed beyond requiring a real regular file to exist."""
if not plex_path:
return None
try:
p = Path(map_path(db, plex_path)).resolve()
except OSError as exc:
# None is answered as a 404 "no local file", which is also the answer for a title the
# server does not hold — so a dead mount or a wrong path map reads as missing media for
# the whole library at once. The path itself stays out: it carries the title.
log.warning(
"a Plex media path could not be resolved locally",
extra=logs.event("plex.paths.resolve_failed", reason=type(exc).__name__),
)
return None
return p if p.is_file() else None