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.
294 lines
13 KiB
Python
294 lines
13 KiB
Python
"""Thin synchronous Plex Media Server client (metadata + images + watch-state).
|
|
|
|
Server-side ONLY: uses the admin ``X-Plex-Token`` from sysconfig. Playback never goes through
|
|
this client — Siftlode plays the LOCAL physical file (see app.plex.paths). Plex is used purely for
|
|
metadata, image proxying, and (P5) optional watch-state write-back. Requests JSON via the
|
|
``Accept: application/json`` header (Plex serves XML by default).
|
|
"""
|
|
import logging
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import sysconfig
|
|
from app.config import settings
|
|
|
|
log = logging.getLogger("siftlode.plex")
|
|
|
|
|
|
class PlexError(Exception):
|
|
"""Any failure talking to the Plex server (network, HTTP status, bad payload)."""
|
|
|
|
|
|
class PlexNotConfigured(PlexError):
|
|
"""The Plex server URL or token isn't set — the module can't do anything."""
|
|
|
|
|
|
class PlexClient:
|
|
"""Bound to the configured server + admin token. Use as a context manager so the underlying
|
|
httpx client is closed."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.base = sysconfig.get_str(db, "plex_server_url").rstrip("/")
|
|
self.token = sysconfig.get_str(db, "plex_server_token")
|
|
if not self.base or not self.token:
|
|
raise PlexNotConfigured("Plex server URL or token is not configured")
|
|
self._http = httpx.Client(
|
|
timeout=30.0,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"X-Plex-Token": self.token,
|
|
"X-Plex-Client-Identifier": settings.plex_client_id,
|
|
"X-Plex-Product": "Siftlode",
|
|
},
|
|
)
|
|
|
|
def __enter__(self) -> "PlexClient":
|
|
return self
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
self._http.close()
|
|
|
|
def close(self) -> None:
|
|
self._http.close()
|
|
|
|
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
try:
|
|
r = self._http.get(f"{self.base}{path}", params=params)
|
|
r.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
raise PlexError(str(e)) from e
|
|
try:
|
|
return (r.json() or {}).get("MediaContainer", {}) or {}
|
|
except ValueError as e:
|
|
raise PlexError("Plex returned a non-JSON response") from e
|
|
|
|
# --- Connectivity + sections (P0) ---
|
|
def server_info(self) -> dict:
|
|
"""Root identity (friendlyName / version) — used by the admin 'Test connection'."""
|
|
return self._get("/")
|
|
|
|
def sections(self) -> list[dict]:
|
|
"""Library sections (Directory entries): key, title, type (movie|show|...)."""
|
|
mc = self._get("/library/sections")
|
|
return mc.get("Directory", []) or []
|
|
|
|
# --- Catalog mirror (P1) ---
|
|
def section_items(
|
|
self, key: str, item_type: int, start: int = 0, size: int = 200
|
|
) -> tuple[list[dict], int]:
|
|
"""A page of a section's top-level items. type 1 = movie, 2 = show. Returns
|
|
(items, total_size)."""
|
|
mc = self._get(
|
|
f"/library/sections/{key}/all",
|
|
params={
|
|
"type": item_type,
|
|
"X-Plex-Container-Start": start,
|
|
"X-Plex-Container-Size": size,
|
|
},
|
|
)
|
|
return mc.get("Metadata", []) or [], int(mc.get("totalSize", mc.get("size", 0)) or 0)
|
|
|
|
def children(self, rating_key: str) -> list[dict]:
|
|
"""Direct children (show → seasons, season → episodes)."""
|
|
mc = self._get(f"/library/metadata/{rating_key}/children")
|
|
return mc.get("Metadata", []) or []
|
|
|
|
def related(self, rating_key: str) -> list[dict]:
|
|
"""Related / similar items for a movie or show, flattened out of Plex's Hub grouping.
|
|
Best-effort (needs the library's related data) — returns [] if Plex has none."""
|
|
try:
|
|
mc = self._get(f"/library/metadata/{rating_key}/related")
|
|
except PlexError:
|
|
return []
|
|
out: list[dict] = []
|
|
for hub in mc.get("Hub", []) or []:
|
|
out.extend(hub.get("Metadata", []) or [])
|
|
return out
|
|
|
|
def collections(self, section_key: str) -> list[dict]:
|
|
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
|
mc = self._get(f"/library/sections/{section_key}/collections")
|
|
return mc.get("Metadata", []) or []
|
|
|
|
def collection_children(self, rating_key: str) -> list[dict]:
|
|
"""The member items (movies / shows) of a collection."""
|
|
mc = self._get(f"/library/collections/{rating_key}/children")
|
|
return mc.get("Metadata", []) or []
|
|
|
|
# --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) ---
|
|
@property
|
|
def machine_id(self) -> str:
|
|
"""Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached."""
|
|
if not getattr(self, "_machine_id", None):
|
|
self._machine_id = self._get("/identity").get("machineIdentifier") or ""
|
|
return self._machine_id
|
|
|
|
def _write(self, method: str, path: str) -> dict:
|
|
try:
|
|
r = self._http.request(method, f"{self.base}{path}")
|
|
r.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
raise PlexError(str(e)) from e
|
|
try:
|
|
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
|
|
except ValueError:
|
|
return {}
|
|
|
|
def _metadata_uri(self, *rating_keys: str) -> str:
|
|
keys = ",".join(str(k) for k in rating_keys)
|
|
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
|
|
|
|
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
|
|
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
|
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
|
mc = self._write(
|
|
"POST",
|
|
f"/library/collections?type=1&smart=0§ionId={section_key}"
|
|
f"&title={quote(title, safe='')}&uri={uri}",
|
|
)
|
|
items = mc.get("Metadata", []) or []
|
|
if not items:
|
|
raise PlexError("Plex did not return the created collection")
|
|
return items[0]
|
|
|
|
def add_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
|
|
uri = quote(self._metadata_uri(item_rating_key), safe="")
|
|
self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}")
|
|
|
|
def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
|
|
self._write("DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}")
|
|
|
|
def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None:
|
|
self._write(
|
|
"PUT",
|
|
f"/library/sections/{section_key}/all?type=18&id={collection_rating_key}"
|
|
f"&title.value={quote(title, safe='')}&title.locked=1",
|
|
)
|
|
|
|
def delete_collection(self, collection_rating_key: str) -> None:
|
|
self._write("DELETE", f"/library/collections/{collection_rating_key}")
|
|
|
|
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
|
|
"""Full metadata for one item; include intro/credit markers when requested."""
|
|
params = {"includeMarkers": 1} if markers else None
|
|
mc = self._get(f"/library/metadata/{rating_key}", params=params)
|
|
items = mc.get("Metadata", []) or []
|
|
return items[0] if items else None
|
|
|
|
# --- Watch-state write-back (P5 Phase B — Siftlode→Plex push; acts on the token's account) ---
|
|
_LIBRARY_IDENTIFIER = "com.plexapp.plugins.library"
|
|
|
|
def scrobble(self, rating_key: str) -> None:
|
|
"""Mark the item *watched* on the token's Plex account (viewCount++, resume cleared)."""
|
|
self._write(
|
|
"GET",
|
|
f"/:/scrobble?identifier={self._LIBRARY_IDENTIFIER}"
|
|
f"&key={quote(str(rating_key), safe='')}",
|
|
)
|
|
|
|
def unscrobble(self, rating_key: str) -> None:
|
|
"""Mark the item *unwatched* on the token's Plex account (clears viewCount)."""
|
|
self._write(
|
|
"GET",
|
|
f"/:/unscrobble?identifier={self._LIBRARY_IDENTIFIER}"
|
|
f"&key={quote(str(rating_key), safe='')}",
|
|
)
|
|
|
|
def set_timeline(
|
|
self, rating_key: str, time_ms: int, duration_ms: int, state: str = "stopped"
|
|
) -> None:
|
|
"""Report a resume position (viewOffset) for the item on the token's account. `state` is a
|
|
Plex timeline state (playing|paused|stopped); we push the settled position as ``stopped``."""
|
|
key = quote(f"/library/metadata/{rating_key}", safe="")
|
|
self._write(
|
|
"GET",
|
|
f"/:/timeline?identifier={self._LIBRARY_IDENTIFIER}"
|
|
f"&ratingKey={quote(str(rating_key), safe='')}&key={key}"
|
|
f"&state={state}&time={int(time_ms)}&duration={int(duration_ms)}",
|
|
)
|
|
|
|
# --- Watch-state read for incremental sync (P5 Phase C) ---
|
|
def accounts(self) -> list[dict]:
|
|
"""Server accounts (the owner + any managed/home users): id, name. Used to resolve the
|
|
owner's accountID so the shared watch-history feed can be filtered to just their views."""
|
|
return self._get("/accounts").get("Account", []) or []
|
|
|
|
def watch_history(
|
|
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500, max_pages: int = 40
|
|
) -> list[dict]:
|
|
"""The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at`
|
|
(epoch seconds), newest first — works without Plex Pass. The efficient "what changed since T"
|
|
feed, filtered client-side to `account_id`.
|
|
|
|
The history is a GLOBAL feed across all server accounts, so paginate (sorted desc) until we
|
|
reach a row at/older than the cutoff: on a busy family server the owner's recent views can sit
|
|
past the first page behind other accounts' rows. `max_pages` bounds the scan (the daily full
|
|
reconcile is the backstop for anything beyond it)."""
|
|
out: list[dict] = []
|
|
for page in range(max_pages):
|
|
mc = self._get(
|
|
"/status/sessions/history/all",
|
|
params={
|
|
"sort": "viewedAt:desc",
|
|
"X-Plex-Container-Start": page * size,
|
|
"X-Plex-Container-Size": size,
|
|
},
|
|
)
|
|
rows = mc.get("Metadata", []) or []
|
|
reached_cutoff = False
|
|
for r in rows:
|
|
if int(r.get("viewedAt") or 0) < min_viewed_at:
|
|
reached_cutoff = True
|
|
break
|
|
if account_id is not None and int(r.get("accountID") or -1) != account_id:
|
|
continue
|
|
out.append(r)
|
|
if reached_cutoff or len(rows) < size:
|
|
break
|
|
else:
|
|
log.warning(
|
|
"watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail",
|
|
max_pages, min_viewed_at,
|
|
)
|
|
return out
|
|
|
|
def on_deck(self) -> list[dict]:
|
|
"""The token account's Continue-Watching list (ratingKey / viewOffset / lastViewedAt /
|
|
type) — the incremental source for resume-position changes made on the Plex side."""
|
|
return self._get("/library/onDeck").get("Metadata", []) or []
|
|
|
|
def raw_get(self, path: str) -> bytes:
|
|
"""Raw bytes of an arbitrary Plex resource path (e.g. an external subtitle stream `key` like
|
|
``/library/streams/553184``). Keeps the admin token server-side."""
|
|
try:
|
|
r = self._http.get(f"{self.base}{path}")
|
|
r.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
raise PlexError(str(e)) from e
|
|
return r.content
|
|
|
|
def image_bytes(
|
|
self, image_path: str, width: int | None = None, height: int | None = None
|
|
) -> tuple[bytes, str]:
|
|
"""Raw bytes + content-type of a Plex thumb/art image (proxied to the browser). The
|
|
image_path is a Plex path like ``/library/metadata/123/thumb/456``.
|
|
|
|
With width+height, Plex's photo transcoder resizes server-side so the browser fetches a small
|
|
image (~tens of KB) instead of the multi-MB full-res original — far faster grid scroll + a
|
|
much smaller disk cache. ``minSize=1`` fits within the box; ``upscale=0`` never enlarges."""
|
|
if width and height:
|
|
path = (
|
|
f"/photo/:/transcode?width={width}&height={height}"
|
|
f"&minSize=1&upscale=0&url={quote(image_path, safe='')}"
|
|
)
|
|
else:
|
|
path = image_path
|
|
try:
|
|
r = self._http.get(f"{self.base}{path}")
|
|
r.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
raise PlexError(str(e)) from e
|
|
return r.content, r.headers.get("Content-Type", "image/jpeg")
|