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:
parent
f90c12beaa
commit
28061353ec
4 changed files with 164 additions and 115 deletions
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue