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

@ -14,6 +14,7 @@ import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
@ -386,6 +387,37 @@ def _apply_meta_filters(q, model, p: dict):
return q.filter(*conds) if conds else q
@dataclass
class LibraryFilters:
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included
for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately)."""
genres: str | None = None
genre_mode: str = "any"
content_ratings: str | None = None
year_min: int | None = None
year_max: int | None = None
rating_min: float | None = None
duration_min: int | None = None
duration_max: int | None = None
added_within: str | None = None
directors: str | None = None
actors: str | None = None
studios: str | None = None
collection: str | None = None
def as_dict(self) -> dict:
return {
"genres": self.genres, "genre_mode": self.genre_mode,
"content_ratings": self.content_ratings, "year_min": self.year_min,
"year_max": self.year_max, "rating_min": self.rating_min,
"duration_min": self.duration_min, "duration_max": self.duration_max,
"added_within": self.added_within, "directors": self.directors,
"actors": self.actors, "studios": self.studios, "collection": self.collection,
}
@router.get("/library")
def unified_library(
scope: str = "both",
@ -395,19 +427,7 @@ def unified_library(
show: str = "all",
offset: int = 0,
limit: int = Query(default=40, ge=1, le=100),
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@ -416,12 +436,7 @@ def unified_library(
its episodes). On a search that also matches episodes (and shows are in scope), the matching
episodes come back in a separate `episodes` list (grouped result the 'Episodes' section)."""
offset = max(0, offset)
p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"added_within": added_within, "directors": directors, "actors": actors,
"studios": studios, "collection": collection,
}
p = f.as_dict()
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
@ -467,10 +482,10 @@ def unified_library(
else:
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
mq = _apply_meta_filters(mq, PlexItem, p)
if duration_min is not None:
mq = mq.filter(PlexItem.duration_s >= duration_min)
if duration_max is not None:
mq = mq.filter(PlexItem.duration_s <= duration_max)
if f.duration_min is not None:
mq = mq.filter(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.filter(PlexItem.duration_s <= f.duration_max)
if tsq is not None:
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
selects.append(mq)
@ -576,19 +591,7 @@ def unified_library(
def facets(
scope: str = "both",
show: str = "all",
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@ -611,12 +614,7 @@ def facets(
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
}
p = f.as_dict()
uid = user.id
@ -714,7 +712,7 @@ def facets(
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in _csv(genres):
for g in _csv(f.genres):
genre_counts.setdefault(g, 0)
return {
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
@ -1828,8 +1826,12 @@ def _push_watch(
spinning up a throwaway background session on every state change for the common no-link case."""
if plex_watch.link_for_push(db, user_id) is None:
return
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
background.add_task(
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms,
plex_watch._state_marker(st),
)