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.
This commit is contained in:
2026-09-18 01:20:42 +02:00
parent 0cff964ff2
commit 6b0401b922
47 changed files with 792 additions and 96 deletions
+14 -1
View File
@@ -105,7 +105,18 @@ class PlexClient:
Best-effort (needs the library's related data) — returns [] if Plex has none."""
try:
mc = self._get(f"/library/metadata/{rating_key}/related")
except PlexError:
except PlexError as exc:
# An empty list is also the answer for an item that genuinely has no related titles,
# so the caller cannot tell the two apart — and it deliberately declines to CACHE an
# empty one for exactly that reason. This line is what says which it was.
log.debug(
"the related items for a Plex item could not be read",
extra=logs.event(
"plex.client.related_unavailable",
rating_key=rating_key,
reason=type(exc).__name__,
),
)
return []
out: list[dict] = []
for hub in mc.get("Hub", []) or []:
@@ -138,6 +149,8 @@ class PlexClient:
raise PlexError(str(e)) from e
try:
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
# silent-ok: the HTTP call already succeeded — this is a write endpoint, and Plex answers
# those with an empty or non-JSON body as a matter of course.
except ValueError:
return {}
+2
View File
@@ -22,6 +22,8 @@ def rich_detail(meta: dict) -> dict:
if str(r.get("image") or "").startswith("imdb://"):
try:
imdb_rating = round(float(r.get("value")), 1)
# silent-ok: no IMDb score, which is the common and legitimate case — the UI has a
# separate audience score to fall back on and shows nothing if neither exists.
except (TypeError, ValueError):
pass
break
+13 -2
View File
@@ -11,10 +11,13 @@ The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see
from fastapi import Response
from sqlalchemy.orm import Session
from app import logs
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR
from app.plex.lookups import by_rating_key
log = logs.get_logger(__name__)
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
@@ -46,6 +49,8 @@ def read(key: str) -> tuple[bytes, str] | None:
try:
if p.exists() and p.stat().st_size > 0:
return p.read_bytes(), ct
# silent-ok: a miss. The caller fetches from Plex and writes the file again, and the write
# path below is the one that reports a cache that cannot be used.
except OSError:
pass
return None
@@ -58,8 +63,14 @@ def write(key: str, data: bytes, ct: str) -> None:
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
tmp.write_bytes(data)
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
except OSError:
pass # cache is best-effort
except OSError as exc:
# Best-effort, and silently so: with an unwritable cache every poster on every grid goes
# to Plex live, for as long as nobody notices. Debug, because a grid is ~100 images and
# the point is the condition, not the count.
log.debug(
"the Plex image cache could not be written",
extra=logs.event("plex.imgcache.write_failed", reason=type(exc).__name__),
)
def response(data: bytes, ct: str) -> Response:
+11 -2
View File
@@ -12,7 +12,9 @@ from pathlib import Path
from sqlalchemy.orm import Session
from app import sysconfig
from app import logs, sysconfig
log = logs.get_logger(__name__)
def parse_map(raw: str) -> list[tuple[str, str]]:
@@ -47,6 +49,13 @@ def local_media_path(db: Session, plex_path: str | None) -> Path | None:
return None
try:
p = Path(map_path(db, plex_path)).resolve()
except OSError:
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
+76 -10
View File
@@ -230,7 +230,15 @@ def _probe_video_kind(src: Path) -> str:
text=True,
timeout=10,
)
except (subprocess.SubprocessError, OSError):
except (subprocess.SubprocessError, OSError) as exc:
# The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
# ffprobe sends every HDR file down it, so playback quietly degrades to a software
# transcode with nothing anywhere saying why. The path stays out of the line: it is the
# title of whatever is playing.
log.info(
"video-kind probe failed; assuming the HW-safe pipeline",
extra=logs.event("plex.stream.probe_kind_failed", reason=type(exc).__name__),
)
return _VK_HWSAFE
pix_fmt = bits = transfer = ""
for line in out.stdout.splitlines():
@@ -259,7 +267,14 @@ def _opencl_present() -> bool:
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
try:
return any(_OPENCL_ICD_DIR.glob("*.icd"))
except OSError:
except OSError as exc:
# A MISSING directory does not come through here — `glob` simply yields nothing for one.
# This is a directory we cannot read, and the answer it forces (no OpenCL) costs every HDR
# source the hardware tonemap path.
log.info(
"the OpenCL ICD directory could not be read; treating OpenCL as absent",
extra=logs.event("plex.stream.opencl_probe_failed", reason=type(exc).__name__),
)
return False
@@ -445,6 +460,8 @@ def _sw_child_preexec() -> None:
playback."""
try:
os.nice(_SW_TRANSCODE_NICE)
# silent-ok: this runs in the forked child, between fork and exec. Logging there can deadlock
# on a lock some other thread held at fork time, and the child is about to become ffmpeg.
except OSError:
pass
@@ -471,7 +488,14 @@ def _probe_audio_langs(src: Path) -> list[str]:
timeout=10,
)
return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError):
except (subprocess.SubprocessError, OSError) as exc:
# An empty list IS the single-audio path, so a failed probe silently collapses a file with
# several audio tracks down to one — and the viewer gets a player with no language picker
# rather than an error.
log.warning(
"audio-track probe failed; falling back to a single-audio session",
extra=logs.event("plex.stream.probe_audio_failed", reason=type(exc).__name__),
)
return []
@@ -580,8 +604,14 @@ def _probe_first_pts(seg: Path) -> float | None:
tok = line.strip().strip(",")
if tok:
return float(tok)
except (subprocess.SubprocessError, ValueError, OSError):
pass
except (subprocess.SubprocessError, ValueError, OSError) as exc:
# None means "keep the offset the client asked for", and the whole point of this probe is
# that the two differ: without it the subtitle track is shifted by whatever the container
# started its timestamps at. Seconds of drift, and nothing on screen calls it a failure.
log.warning(
"first-PTS probe failed; the requested offset is used as-is",
extra=logs.event("plex.stream.first_pts_probe_failed", reason=type(exc).__name__),
)
return None
@@ -590,10 +620,18 @@ def _kill(s: HlsSession) -> None:
s.proc.terminate()
try:
s.proc.wait(timeout=3)
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
# seconds gets KILL, which is exactly what this branch does.
except subprocess.TimeoutExpired:
s.proc.kill()
except Exception:
pass
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
# `_sessions` rather than processes. One line is the only way that ever surfaces.
log.info(
"could not stop the transcode process",
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
)
shutil.rmtree(s.dir, ignore_errors=True)
@@ -865,7 +903,20 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
return True
try:
return (s.dir / segment_name(seq - 1, variant)).exists()
except OSError:
except OSError as exc:
# `exists()` answers False for a missing file without raising, so this is the session
# directory itself refusing — a full or unmounted disk. Every segment then 404s, which the
# player shows as a stall. Per-segment, but the sink groups by message template, so a
# broken session is one grouped line rather than one per segment.
log.warning(
"a session segment could not be checked",
extra=logs.event(
"plex.stream.segment_probe_failed",
key=s.key,
seq=seq,
reason=type(exc).__name__,
),
)
return False
@@ -882,12 +933,16 @@ def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None =
try:
if path.exists() and path.stat().st_size > 0:
return True
# silent-ok: this is a 150 ms poll — a line here is a line every 150 ms for the whole
# timeout. The final check below decides, and the 404 the caller then returns is honest.
except OSError:
pass
if proc is not None and proc.poll() is not None:
# the producer exited — no more segments are coming; one last check then give up.
try:
return path.exists() and path.stat().st_size > 0
# silent-ok: one last look after the producer exited. False here becomes a 404 the
# player recovers from, which is the honest answer either way.
except OSError:
return False
time.sleep(0.15)
@@ -920,7 +975,16 @@ def sweep_orphans(older_than: float | None = None) -> int:
dropped = 0
try:
entries = list(_HLS_ROOT.iterdir())
except OSError: # the root doesn't exist yet — nothing to sweep
# silent-ok: nothing has been transcoded yet, so there is nothing to sweep.
except FileNotFoundError:
return 0
except OSError as exc:
# Any OTHER reason the root will not list is not "nothing to sweep": the sweep reports 0
# like a clean run while every crashed session's segments stay on disk forever.
log.warning(
"the HLS root could not be listed; no orphans were swept",
extra=logs.event("plex.stream.sweep_root_unreadable", reason=type(exc).__name__),
)
return 0
for p in entries:
if not _SESSION_DIR_RE.match(p.name):
@@ -930,7 +994,9 @@ def sweep_orphans(older_than: float | None = None) -> int:
try:
if p.stat().st_mtime >= cutoff:
continue # this run made (or reused) it — never ours to delete
except OSError: # vanished under us (a concurrent restart of that session)
# silent-ok: vanished under us — a concurrent restart of that session removed it, which
# is the outcome this sweep wanted anyway.
except OSError:
continue
with _lock:
if any(s.dir == p for s in _sessions.values()):
+110 -9
View File
@@ -38,6 +38,7 @@ import subprocess
import tempfile
import threading
import time
import logging
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -103,6 +104,7 @@ def shift_vtt(vtt: str, offset: float) -> str:
straddling 0 is clamped to start at 0."""
if not offset:
return vtt
unshifted = 0
out_blocks: list[str] = []
for block in re.split(r"\n[ \t]*\n", vtt):
if "-->" not in block:
@@ -120,7 +122,12 @@ def shift_vtt(vtt: str, offset: float) -> str:
st = _vtt_ts_to_s(left.strip()) - offset
en = _vtt_ts_to_s(rparts[0]) - offset
except (ValueError, IndexError):
break # unparseable timing → leave the block as-is
# silent-ok: left as-is means left UNSHIFTED — the viewer sees that one cue at
# the wrong moment. Counted rather than logged here: this is per cue, and the file
# being parsed is one ffmpeg wrote for us a moment ago, so a broken timestamp is
# a fact about the extraction, reported once below.
unshifted += 1
break
if en <= 0:
keep = False # whole cue is before the session start → drop the block
else:
@@ -130,6 +137,11 @@ def shift_vtt(vtt: str, offset: float) -> str:
break
if keep:
out_blocks.append("\n".join(lines))
if unshifted:
log.info(
"some cues could not be shifted and stay at their original time",
extra=logs.event("plex.subtitles.cues_unshifted", cues=unshifted, offset_s=offset),
)
return "\n\n".join(out_blocks)
@@ -161,7 +173,15 @@ def cache_read(key: str) -> str | None:
try:
p = CACHE_DIR / f"{key}.vtt"
return p.read_text(encoding="utf-8") if p.exists() else None
except OSError:
except OSError as exc:
# None is the same answer as "never cached", so a cache we cannot read looks exactly like
# a cold one — and every request re-extracts instead of saying the disk is the problem.
log.warning(
"the cached subtitle could not be read",
extra=logs.event(
"plex.subtitles.cache_read_failed", key=key, reason=type(exc).__name__
),
)
return None
@@ -174,7 +194,16 @@ def cache_write(key: str, vtt: str) -> bool:
tmp.write_text(vtt, encoding="utf-8")
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
return True
except OSError:
except OSError as exc:
# `publish` turns False into "the region was not claimed" and moves on, so the track just
# never appears. Which errno it was — full disk, read-only mount, permissions — is the
# whole diagnosis, and it exists only here.
log.warning(
"the subtitle cache could not be written",
extra=logs.event(
"plex.subtitles.cache_write_failed", key=key, reason=type(exc).__name__
),
)
return False
@@ -205,7 +234,17 @@ def read_meta(key: str) -> tuple[list[list[float]], bool]:
try:
raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
except (OSError, ValueError, TypeError):
# silent-ok: no sidecar at all is the documented older-build case the fallback exists for.
except FileNotFoundError:
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
except (OSError, ValueError, TypeError) as exc:
# A sidecar that EXISTS and will not parse is different, and the fallback is the dangerous
# answer to it: beside a cached VTT it reports the track as complete, so a partial track is
# never extended again and the viewer loses every cue past the window that was cached.
log.warning(
"the subtitle sidecar could not be read; the cached track is taken as complete",
extra=logs.event("plex.subtitles.meta_unreadable", key=key, reason=type(exc).__name__),
)
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
@@ -215,6 +254,8 @@ def _exists(p: Path) -> bool:
escaped as a 500 from the status route."""
try:
return p.exists()
# silent-ok: this function's whole job is to answer False for a path the OS refuses. The
# refusal IS the return value; a caller that needs to report one reports it itself.
except (OSError, ValueError):
return False
@@ -225,8 +266,18 @@ def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None:
tmp = CACHE_DIR / f"{key}.json.tmp"
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
tmp.replace(_meta_path(key))
except OSError:
pass
except OSError as exc:
# Without the sidecar beside it, a cached window reads back as a COMPLETE track (see
# `read_meta`) — so one failed write here permanently costs the rest of that subtitle.
log.warning(
"the subtitle sidecar could not be written",
extra=logs.event(
"plex.subtitles.meta_write_failed",
key=key,
complete=complete,
reason=type(exc).__name__,
),
)
def generation(key: str) -> int:
@@ -234,7 +285,9 @@ def generation(key: str) -> int:
window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
try:
return _vtt_path(key).stat().st_mtime_ns
except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright
# silent-ok: 0 for a track that is not cached yet, which is the ordinary case on every first
# request. (ValueError: a NUL in the key — `stat` refuses the path outright.)
except (OSError, ValueError):
return 0
@@ -266,16 +319,27 @@ def merge_vtt(*sources: str) -> str:
happen to share a time (two speakers, a sign over dialogue) are both kept."""
seen: set[str] = set()
cues: list[tuple[float, str]] = []
dropped = 0
for src in sources:
for block in re.split(r"\n[ \t]*\n", src or ""):
canon = _canonical_cue(block.strip())
stripped = block.strip()
canon = _canonical_cue(stripped)
if canon is None:
# A block WITH a timing line that still would not parse is a cue we are throwing
# away, not a header — and the merged track would simply be missing those lines.
if "-->" in stripped:
dropped += 1
continue # header / NOTE / STYLE — the merged file gets its own single header
start, key, text = canon
if key in seen:
continue
seen.add(key)
cues.append((start, text))
if dropped:
log.info(
"some cues were dropped from the merged subtitle: their timing would not parse",
extra=logs.event("plex.subtitles.cues_dropped", cues=dropped, kept=len(cues)),
)
cues.sort(key=lambda c: c[0])
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
@@ -294,6 +358,8 @@ def _canonical_cue(block: str) -> tuple[float, str, str] | None:
try:
st = _vtt_ts_to_s(left.strip())
en = _vtt_ts_to_s(rparts[0])
# silent-ok: None here means "not a cue", and the caller tells a header apart from a
# broken timing line by whether the block had a `-->` at all — see `_merge_vtt`.
except (ValueError, IndexError):
return None
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
@@ -309,6 +375,8 @@ def _last_cue_end(vtt: str) -> float:
continue
try:
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
# silent-ok: this is a floor — "how far the pass provably got". Skipping a line it cannot
# read makes the answer smaller, never larger, so the error is always on the safe side.
except (ValueError, IndexError):
continue
return end
@@ -651,12 +719,35 @@ def _run(
return
_extract_and_publish(rating_key, sub_ord, window_start)
except SubtitleUnavailable as e:
# The player's own state was the only place this ever showed. `_pass_failed` backs the item
# off for an hour and `_fail` records a code, but neither leaves a line, so "my subtitles
# never appear" had nothing on the server to read.
# A final code is a fact about the file (no such track, a bitmap subtitle); anything else
# is this instance failing at something it could do.
log.log(
logging.INFO if e.code in FINAL_CODES else logging.WARNING,
"subtitle extraction gave up",
extra=logs.event(
"plex.subtitles.pass_failed",
rating_key=rating_key,
sub_ord=sub_ord,
code=e.code,
windowed=window_start is not None,
),
)
if e.code == "extract_failed":
_pass_failed(rating_key, [key], window_start)
else:
_fail(key, e.code)
except PlexError as e:
log.warning("subtitle extraction failed for %s/%s: %s", rating_key, sub_ord, e)
# No traceback on purpose: a PlexError carries httpx's own message, which names the method,
# the URL and the status — the stack under it is this `except` and the client call, and
# says nothing the message does not. (The URL holds no token; it travels as a header.)
log.warning(
"subtitle extraction failed talking to Plex: %s",
e,
extra=logs.event("plex.subtitles.plex_error", rating_key=rating_key, sub_ord=sub_ord),
)
_fail(key, "plex_error")
except (
Exception
@@ -828,6 +919,16 @@ def _ffmpeg_extract(
try:
_, err = proc.communicate(timeout=_TERM_GRACE_S)
except subprocess.TimeoutExpired:
# TERM is what makes a timed-out pass worth keeping: ffmpeg closes its outputs
# on it. An ffmpeg that ignores TERM leaves only whole blocks already flushed,
# so the partial track is short for a reason the later line cannot tell apart.
log.info(
"the subtitle extraction ignored TERM and was killed",
extra=logs.event(
"plex.subtitles.extract_killed_after_term",
windowed=start is not None,
),
)
proc.kill()
_, err = proc.communicate()
finally:
+22 -8
View File
@@ -100,6 +100,9 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
bd = s.get("bitDepth")
try:
depth = int(bd) if bd else None
# silent-ok: unknown bit depth, which is also what most listings report. The
# playback class then falls back to the codec alone, and per-stream noise over a
# whole library would drown the sync's own lines.
except (TypeError, ValueError):
depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
@@ -110,6 +113,8 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
def _epoch(v) -> datetime | None:
try:
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
# silent-ok: a field Plex leaves out of most listing rows. None is a legitimate stored value,
# and every caller treats it as "not known" rather than as an error.
except (TypeError, ValueError, OSError):
return None
@@ -118,6 +123,7 @@ def _date(v):
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
try:
return datetime.strptime(v, "%Y-%m-%d").date() if v else None
# silent-ok: as `_epoch` above — an absent or unparsable air date is stored as no air date.
except (TypeError, ValueError):
return None
@@ -133,6 +139,7 @@ def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
def _rating(meta: dict) -> float | None:
try:
return float(meta.get("audienceRating") or meta.get("rating"))
# silent-ok: TypeError on `float(None)` is the ordinary path — most items carry no rating.
except (TypeError, ValueError):
return None
@@ -264,15 +271,19 @@ def sync(db: Session) -> dict:
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try:
stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e)
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
# With the traceback: a blind catch over a batch that fetches AND writes can be
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
log.exception("the Plex people-enrich batch failed")
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try:
stats["media_probed"] = _probe_media_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex media probe batch failed: %s", e)
except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
log.exception("the Plex media-probe batch failed")
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
# logs the job result, and to the admin route, which answers it to the caller.
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
@@ -283,8 +294,8 @@ def sync(db: Session) -> dict:
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try:
stats["people_pruned"] = people.prune_orphan_people(db)
except Exception as e: # best-effort housekeeping — never fail the sync over it
log.warning("Plex people prune failed: %s", e)
except Exception: # noqa: BLE001 — best-effort housekeeping; never fail the sync over it
log.exception("the Plex people prune failed")
log.info("Plex sync done: %s", stats)
return stats
@@ -323,7 +334,9 @@ def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH)
for row in rows:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
# No traceback and only debug: this is per item over a whole library, and the stack is
# the same `plex.metadata` call every time. The retry next run is the handling.
except Exception as e: # noqa: BLE001 — transient; leave media_probed_at NULL to retry
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
continue
bit_depth, hdr = _video_stream_props(meta)
@@ -370,7 +383,8 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
for row in [*movies, *shows]:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e:
# As in `_probe_media_batch` above: per item, so the message without the stack.
except Exception as e: # noqa: BLE001 — transient; leave people_enriched_at NULL
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
continue # leave people_enriched_at NULL so it's retried on a later run
people.enrich_row_people(db, row, people.cast_from_meta(meta))
+17 -1
View File
@@ -140,6 +140,12 @@ def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int
try:
return int(ext)
except ValueError:
# The caller stamps the watermark on a None, so this title is never looked at again:
# one unreadable guid costs it its artwork and its cast permanently.
log.info(
"a Plex guid named TMDB but did not carry a numeric id",
extra=logs.event("plex.tmdb.guid_unparsable", provider=provider),
)
return None
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
if not source:
@@ -497,7 +503,17 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
# doesn't tie up a threadpool worker for the default 30s.
with TmdbClient(db, timeout=8.0) as tmdb:
payload = tmdb.fetch_person(person.tmdb_person_id)
except TmdbError:
except TmdbError as exc:
# False leaves the person un-enriched and unstamped, so it is retried — but the page in
# front of the user right now shows an empty biography with nothing to say why.
log.warning(
"the TMDB person lookup failed",
extra=logs.event(
"plex.tmdb.person_enrich_failed",
person_id=person.id,
reason=type(exc).__name__,
),
)
return False
if payload is None:
person.tmdb_enriched_at = datetime.now(timezone.utc)
+20 -2
View File
@@ -17,6 +17,10 @@ and the incremental pull resolves a genuine divergence to Plex's state (see `_pu
running a last-write-wins comparison.
"""
# On the PlexError lines in this module: none of them carries a traceback, deliberately. A
# PlexError is httpx's own message — the method, the URL and the status of one call — and the stack
# beneath it is always this `except` plus `PlexClient._get`. The attrs beside the line say which
# user and which phase, which is the part the message cannot know.
from datetime import datetime, timezone
from sqlalchemy import func, select, update
@@ -146,6 +150,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
link.initial_import_done = True
link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit()
# silent-ok: the `{"skipped": …}` return is the report — the admin route answers it and the
# scheduler logs it on the job line.
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
@@ -324,8 +330,18 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
if int(a.get("id") or -1) == 1:
username = a.get("name")
break
except PlexError:
pass
except PlexError as exc:
# The fallback account id 1 is right for the admin-token setup, so sync keeps working —
# but the username is never stored, and the UI then has no name to show for the link,
# exactly as if the server had not offered one.
log.info(
"the Plex account list could not be read; the link keeps the default account",
extra=logs.event(
"plex.watch.accounts_probe_failed",
user_id=link.user_id,
reason=type(exc).__name__,
),
)
link.plex_account_id = acct_id
if username:
link.plex_username = username
@@ -564,6 +580,7 @@ def run_plex_watch_sync(db: Session) -> dict:
total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = now
db.commit()
# silent-ok: the `{"skipped": …}` return is the report, as above.
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
@@ -615,6 +632,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit()
# silent-ok: the `{"skipped": …}` return is the report, as above.
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e: