fix(e3): address /code-review high findings (5)

Round-1 review fixes on the E3 branch:

- prune partial-fetch (correctness): `_paginate` now reports whether it reached
  the server's reported `total`; `_prune_stale` prunes only on a COMPLETE
  non-empty listing. A truncated fetch (a mid-sequence empty/short page before
  total — a transient Plex hiccup) no longer deletes the rows it merely failed
  to list. New test: a listing that claims 5 but returns 1 prunes nothing.
- `_prune_stale` (robustness): `stats.get("pruned", 0) + 1` — no KeyError when a
  caller's stats dict omits the key.
- service transitions (conventions): pause/resume/redownload use the JobStatus
  StrEnum instead of loose "queued"/"paused"/"error" literals, matching cancel.
- ffmpeg progress writer (correctness): a stuck-percent heartbeat now runs on its
  OWN 1/interval budget, so it can't consume the progress-write slot and delay a
  real percent change by up to an interval.
- backend/app/README.md (conventions): reword the transaction-ownership rule to
  quote and extend CLAUDE.md's canonical ("handler/job-wrapper commits; helpers
  flush; only quota owns its session") instead of stating a contradicting
  "helpers MAY commit" carve-out.

ruff clean, full DB-lane 364 pass.
This commit is contained in:
2026-08-18 22:47:25 +02:00
parent b02a9e37e8
commit bc79720e81
6 changed files with 96 additions and 39 deletions
+33 -16
View File
@@ -284,8 +284,14 @@ def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary
return lib
def _paginate(plex: PlexClient, key: str, item_type: int):
def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | None = None):
"""Yield a section's items page by page. If `progress` is given, set `progress["complete"]` True
ONLY when the walk reached the server's reported `total` — a truncated fetch (an empty/short page
mid-sequence, e.g. a transient Plex hiccup, before `total` is reached) leaves it False so the
caller can skip pruning. Pruning on a partial listing would delete rows the listing merely failed
to mention (the metadata is re-syncable, but that still cascades watch-state)."""
start = 0
total = None
while True:
items, total = plex.section_items(key, item_type, start, _PAGE)
if not items:
@@ -297,6 +303,10 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
break
if len(items) < _PAGE:
break
if progress is not None:
# A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows
# (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative.
progress["complete"] = bool(total) and start >= total
# The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this
@@ -356,18 +366,21 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.playable = classify_playable(mf.get("codec_video"), mf.get("codec_audio"), mf.get("container"))
def _prune_stale(db: Session, existing: dict, seen: set[str], stats: dict) -> None:
def _prune_stale(
db: Session, existing: dict, seen: set[str], stats: dict, *, complete: bool
) -> None:
"""Delete stored rows whose rating_key is no longer in the freshly-listed `seen` set — the titles
that disappeared from Plex (their watch-state / playlist links cascade via the FKs). GUARDED: an
empty `seen` (a section that listed zero items — a real empty library, or a transient Plex glitch
we can't tell apart) prunes NOTHING, so a hiccup can never wipe a whole library. Callers pass the
rating_key -> row map they already built for the upsert."""
if not seen:
that disappeared from Plex (their watch-state / playlist links cascade via the FKs). GUARDED on a
COMPLETE, non-empty listing: a truncated fetch (`complete` False — a mid-sequence empty/short page
before the server's total) or a zero-item listing prunes NOTHING, so a transient Plex hiccup can
never wipe a library over rows it merely failed to list. Callers pass the rating_key -> row map
they already built for the upsert."""
if not complete or not seen:
return
for rk, row in existing.items():
if rk not in seen:
db.delete(row)
stats["pruned"] += 1
stats["pruned"] = stats.get("pruned", 0) + 1
db.flush()
@@ -379,7 +392,8 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
).scalars()
}
seen: set[str] = set()
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
progress: dict = {}
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE, progress):
rk = str(meta.get("ratingKey") or "")
if not rk:
continue
@@ -390,7 +404,7 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
seen.add(rk)
stats["movies"] += 1
db.flush()
_prune_stale(db, existing, seen, stats)
_prune_stale(db, existing, seen, stats, complete=progress.get("complete", False))
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
@@ -402,7 +416,8 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
).scalars()
}
shows_seen: set[str] = set()
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
shows_progress: dict = {}
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW, shows_progress):
rk = str(meta.get("ratingKey") or "")
if not rk:
continue
@@ -433,7 +448,8 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
).scalars()
}
seasons_seen: set[str] = set()
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
seasons_progress: dict = {}
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON, seasons_progress):
rk = str(meta.get("ratingKey") or "")
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
if not rk or sid is None:
@@ -459,7 +475,8 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
).scalars()
}
episodes_seen: set[str] = set()
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
episodes_progress: dict = {}
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE, episodes_progress):
rk = str(meta.get("ratingKey") or "")
if not rk:
continue
@@ -478,9 +495,9 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
# Prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly, before its
# parent's cascade would reach it — avoids deleting the same ORM object twice. Each level is
# independently guarded on a non-empty listing by _prune_stale.
_prune_stale(db, episodes, episodes_seen, stats)
_prune_stale(db, seasons, seasons_seen, stats)
_prune_stale(db, shows, shows_seen, stats)
_prune_stale(db, episodes, episodes_seen, stats, complete=episodes_progress.get("complete", False))
_prune_stale(db, seasons, seasons_seen, stats, complete=seasons_progress.get("complete", False))
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
def _sync_overlay(