Plex backend pass (#9): watch_sync bugs + backend dedup

Closes the Plex #9 backend backlog (frontend was the prior follow-up). Behavior-
neutral cleanup + two two-way-sync bug fixes; PW2 verified a non-bug.

fix(plex): watch-sync PW1 pagination + PW3 lost-update guard
- PW1 watch_history: the history is a GLOBAL cross-account feed read as a single
  500-row page, then filtered to the owner — on a busy family server the owner's
  recent views can sit past page 1 and be missed. Now paginate (desc) until the
  since-cutoff, bounded by max_pages (daily reconcile is the backstop; logs if hit).
- PW3 push_state_to_plex: it flagged synced_to_plex=True unconditionally after a
  push, so a local edit landing between the push and the flag-set was marked clean
  and never pushed (lost update). Capture the row's freshest timestamp when the
  push is scheduled (_push_watch) and only settle the flag if the row is unchanged.
- PW2 was flagged as "accountID hardcoded to 1" but is NOT a bug: on a PMS account 1
  is reserved for the owner/admin and an owner link always holds the admin token —
  added a clarifying comment so it isn't re-flagged.

chore(plex): backend dedup
- PC2 unified_library + facets shared a ~13-field filter Query signature + p-dict →
  one LibraryFilters dataclass injected via Depends(); as_dict() feeds the builders.
- PS-C1 sync.py collection upsert dup (resync_collection ≈ _sync_collections) →
  _upsert_collection().
- PS-C2 sync.py 8-field filterable-metadata block dup (_apply_item ≈ _sync_shows) →
  _apply_facet_fields().
- PW-C1 _repush_dirty read duration from the mirrored PlexItem.duration_s instead of
  a per-row plex.metadata() round-trip.
- PW-C2 rk→id map built inline in two places → _rk_to_id() helper.
This commit is contained in:
npeter83 2026-07-11 23:48:35 +02:00
parent f90c12beaa
commit 28061353ec
4 changed files with 164 additions and 115 deletions

View file

@ -166,6 +166,21 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
break
def _apply_facet_fields(row, meta: dict) -> None:
"""Set the filterable/orderable metadata columns shared by plex_items + plex_shows (identical
column names) from the cheap section listing: rating, content_rating, studio, release date,
genres/directors/cast, and the searchable people blob folded into search_vector at sync time."""
row.rating = _rating(meta)
row.content_rating = (meta.get("contentRating") or None)
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.library_id = lib_id
row.kind = kind
@ -176,17 +191,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.thumb_key = meta.get("thumb")
row.art_key = meta.get("art") or meta.get("grandparentArt")
row.added_at = _epoch(meta.get("addedAt"))
# Filterable / orderable extras — all present in the cheap section listing.
row.rating = _rating(meta)
row.content_rating = (meta.get("contentRating") or None)
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
# Searchable people blob (cast + directors, de-duped) → folded into search_vector at sync time.
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
mf = _media_facts(meta)
row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key")
@ -230,16 +235,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
sh.art_key = meta.get("art")
sh.child_count = meta.get("childCount")
sh.added_at = _epoch(meta.get("addedAt"))
# Filterable / orderable metadata — same cheap section-listing tags as movies.
sh.rating = _rating(meta)
sh.content_rating = (meta.get("contentRating") or None)
sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
sh.originally_available_at = _date(meta.get("originallyAvailableAt"))
sh.genres = _tags(meta, "Genre")
sh.directors = _tags(meta, "Director")
sh.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or [])))
sh.people_text = " ".join(people) or None
_apply_facet_fields(sh, meta) # same filterable/orderable tags as movies
stats["shows"] += 1
db.flush()
show_id = {rk: sh.id for rk, sh in shows.items()}
@ -281,21 +277,32 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
db.flush()
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection:
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this
collection's membership on the affected member rows."""
meta = plex.metadata(rk) or {}
col = db.query(PlexCollection).filter_by(rating_key=rk).first() or PlexCollection(rating_key=rk)
def _upsert_collection(
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
) -> PlexCollection:
"""Get-or-create a PlexCollection row and apply its metadata from a Plex `meta` dict. `editable`
is user-managed and preserved (never set here). Shared by the full sync + the targeted re-sync."""
col = existing or PlexCollection(rating_key=rk)
if col.id is None:
db.add(col)
col.library_id = lib.id
col.library_id = lib_id
col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary")
col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1"
col.synced_at = datetime.now(timezone.utc)
return col
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection:
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this
collection's membership on the affected member rows."""
meta = plex.metadata(rk) or {}
col = _upsert_collection(
db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta
)
Model = PlexItem if lib.kind == "movie" else PlexShow
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
current = db.query(Model).filter(Model.collection_keys.contains([rk])).all()
@ -333,16 +340,7 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
rk = str(meta.get("ratingKey") or "")
if not rk:
continue
col = existing.get(rk) or PlexCollection(rating_key=rk)
if col.id is None:
db.add(col)
col.library_id = lib.id
col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary")
col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1" # `editable` is preserved (user-managed, not here)
col.synced_at = datetime.now(timezone.utc)
_upsert_collection(db, existing.get(rk), rk, lib.id, meta)
seen.add(rk)
for child in plex.collection_children(rk):
crk = str(child.get("ratingKey") or "")