feat(plex): P0 backend foundations — catalog model, config, client

Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.

- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
  (playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
  + plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
  path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
  +markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
This commit is contained in:
npeter83 2026-07-05 01:35:08 +02:00
parent 401fe90256
commit a88254c841
9 changed files with 552 additions and 0 deletions

113
backend/app/plex/client.py Normal file
View file

@ -0,0 +1,113 @@
"""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
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 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 image_bytes(self, image_path: str) -> 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``."""
try:
r = self._http.get(f"{self.base}{image_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")