siftlode/backend/app/plex/client.py

201 lines
8.3 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 []
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
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")