siftlode/backend/app/plex/client.py

295 lines
13 KiB
Python
Raw Normal View History

"""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 []
feat(plex): Plex-web-style 3-level series view (Phase 2 of series view) Restructure TV browsing into show detail → seasons → season episodes → player, like the Plex web app. Backend: /show/{rk} now returns a rich show page — hero meta (rating/content_rating/ genres/studio + live IMDb), live Cast & Crew, Related shows (Plex 'related', mapped to our mirrored shows so they're openable), and per-season cards with an aggregate watch-state + on-deck episode, plus show-level resume (on-deck)/first(play-from-start)/ status rollup. New PlexClient.related(). New bulk-state endpoints POST /show/{rk}/state and /season/{rk}/state mark every episode watched/unwatched for the user and mirror each change to a linked Plex account in the background (best-effort, checked once). Frontend: PlexShowView reworked into the show detail page (hero + Resume/Play-from-start/ Mark-show-watched/Add-to-playlist/[admin]Add-to-collection + season card grid + cast + related strips); new PlexSeasonView season subpage (hero + Resume/Play/Mark-season/ Add-season-to-playlist + landscape episode grid). Both read the one cached ['plex-show'] payload (season page picks its season out of it — instant, no extra fetch). Player queue = the whole show (from the show page) or the season (from the season page) so prev/next + auto-advance follow order. New 'season' history subview; Backspace steps back one drill level (grid←show←season, out of info/playlist) alongside browser/mouse Back. Season-level 'Add to collection' intentionally omitted (Plex collections hold whole shows, not seasons). i18n plex.series.* (en/hu/de).
2026-07-10 22:08:04 +02:00
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&sectionId={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(
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.
2026-07-11 23:48:35 +02:00
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500, max_pages: int = 40
) -> list[dict]:
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.
2026-07-11 23:48:35 +02:00
"""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
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.
2026-07-11 23:48:35 +02:00
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")