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:
parent
401fe90256
commit
a88254c841
9 changed files with 552 additions and 0 deletions
113
backend/app/plex/client.py
Normal file
113
backend/app/plex/client.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue