perf(plex): batch bulk pushes, cache show enrichment; remove dead code + DRY (F6/F8/F10)

F6: coalesce whole-show/season Plex watch-pushes into ONE background task
(push_bulk_state_to_plex) that reuses a single DB session + keep-alive Plex
client, instead of scheduling one task (own session + HTTP client) per episode.

F8: cache a show's live Plex enrichment (metadata + related) per rating_key for
a short TTL and fetch the two calls in parallel; repeat opens skip the network.
Raw payloads are cached; per-user shaping stays out. An empty related list is not
cached (plex.related swallows a transient failure as [] — don't pin it for the TTL).

F10: remove dead code — the pre-unified /browse route + browse(), the /people
route + people() + _person_photo(), api.plexPeople, interface PlexPerson, and the
orphaned plex.people i18n block. DRY: appendPlexFilters() shared by plexLibrary +
plexFacets; one exported plexDetailUi.Filterable (was Fil + Filterable); PlexInfo
migrated onto the shared plexDetailUi hooks/menu (DetailCustomizeMenu gained
overlay + extra props; useDetailPrefs exposes savePref; PrefToggle exported).

Reviewed (high) → clean. F7 (facet aggregate collapse) deferred: self-exclusion
gives each sub-aggregate a distinct WHERE, and no measurement shows /facets slow.
This commit is contained in:
npeter83 2026-07-11 03:10:02 +02:00
parent 9f8e410033
commit b3af04a997
9 changed files with 180 additions and 450 deletions

View file

@ -206,6 +206,51 @@ def push_state_to_plex(
db.commit()
def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None:
"""Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same
contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background
task for the entire batch: it reuses a single DB session and a single keep-alive PlexClient across
all `changes` instead of scheduling N tasks that each rebuild a session + HTTP client. Plex has no
batch-scrobble endpoint, so the per-item HTTP calls remain (looped over the shared connection), but
the O(N) task/session/client setup is gone. `changes` is a list of (item_id, rating_key, action)
with action ``watched`` (scrobble) or ``unwatched`` (unscrobble)."""
if not changes:
return
with SessionLocal() as db:
if link_for_push(db, user_id) is None:
return
synced_ids: list[int] = []
try:
with PlexClient(db) as plex:
for item_id, rating_key, action in changes:
try:
if action == "watched":
plex.scrobble(rating_key)
elif action == "unwatched":
plex.unscrobble(rating_key)
else:
continue
except PlexError as e:
# One bad item mustn't abort the rest of the batch; leave its row dirty
# (synced_to_plex=False) for a later reconcile.
log.warning(
"Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
)
continue
synced_ids.append(item_id)
except PlexError as e:
# Couldn't even open the client — nothing pushed; everything stays dirty for reconcile.
log.warning("Plex bulk push could not connect (user %s): %s", user_id, e)
return
# Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so
# only the "watched" changes have a row to flag).
if synced_ids:
db.query(PlexState).filter(
PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)
).update({PlexState.synced_to_plex: True}, synchronize_session=False)
db.commit()
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just