"""Plex Media Server provider. Talks directly to the server on the LAN with a server token — no plex.tv round trip, so it works whether or not Plex's cloud is up. JSON throughout via the Accept header, which avoids XML parsing entirely (§4.1). """ from __future__ import annotations import logging import time from typing import Iterator import requests from .base import ( Account, AuthError, Coverage, Item, Library, Part, ProviderError, ServerInfo, WatchEvent, ) log = logging.getLogger(__name__) LIBTYPE_MOVIE = 1 LIBTYPE_SHOW = 2 LIBTYPE_SEASON = 3 LIBTYPE_EPISODE = 4 def _int(v, default=0): try: return int(v) except (TypeError, ValueError): return default def _opt_int(v): try: return int(v) except (TypeError, ValueError): return None class PlexClient: def __init__(self, base_url: str, token: str, *, timeout: int = 30, verify_ssl: bool = True, page_size: int = 500, request_delay_ms: int = 0, client_id: str = "mediashelf"): if not base_url or not token: raise ProviderError("Plex is not configured (PLEX_BASE_URL / PLEX_TOKEN)") self.base_url = base_url.rstrip("/") self.timeout = timeout self.verify_ssl = verify_ssl self.page_size = page_size self.delay = request_delay_ms / 1000.0 self.session = requests.Session() self.session.headers.update({ "X-Plex-Token": token, "Accept": "application/json", "X-Plex-Product": "MediaShelf", "X-Plex-Client-Identifier": client_id, }) def get(self, path: str, params: dict | None = None, *, headers: dict | None = None) -> dict: url = self.base_url + path last: Exception | None = None for attempt in range(3): try: if self.delay: time.sleep(self.delay) r = self.session.get(url, params=params, headers=headers, timeout=self.timeout, verify=self.verify_ssl) if r.status_code in (401, 403): raise AuthError("Plex rejected the token (HTTP %s)" % r.status_code) r.raise_for_status() return r.json().get("MediaContainer", {}) or {} except AuthError: raise # never retried: a retry loop will not fix a bad token except Exception as e: # noqa: BLE001 last = e if attempt < 2: time.sleep(2 ** attempt) raise ProviderError("Plex request failed: %s (%s)" % (path, last)) def paged(self, path: str, params: dict | None = None) -> Iterator[dict]: """Yield Metadata rows, paging with X-Plex-Container-* headers.""" start = 0 while True: headers = { "X-Plex-Container-Start": str(start), "X-Plex-Container-Size": str(self.page_size), } mc = self.get(path, params, headers=headers) batch = mc.get("Metadata") or [] if not batch: return for row in batch: yield row start += len(batch) total = _int(mc.get("totalSize") or mc.get("size") or 0) if start >= total: return class PlexProvider: """MediaProvider implementation.""" kind = "plex" def __init__(self, client: PlexClient): self.client = client # ── identity ───────────────────────────────────────────────────────── def server_info(self) -> ServerInfo: mc = self.client.get("/identity") return ServerInfo( kind="plex", name=mc.get("friendlyName") or "Plex", version=mc.get("version") or "", server_id=mc.get("machineIdentifier") or "", base_url=self.client.base_url, ) # ── libraries ──────────────────────────────────────────────────────── def libraries(self) -> list[Library]: mc = self.client.get("/library/sections") out = [] for d in mc.get("Directory") or []: kind = d.get("type") if kind not in ("movie", "show"): continue # music/photo/other are out of scope (§1.2) out.append(Library( provider_key=str(d.get("key")), title=(d.get("title") or "").strip(), kind=kind, locations=[loc.get("path") for loc in (d.get("Location") or []) if loc.get("path")], )) return out # ── items ──────────────────────────────────────────────────────────── def items(self, library: Library) -> Iterator[Item]: libtype = LIBTYPE_MOVIE if library.kind == "movie" else LIBTYPE_EPISODE path = "/library/sections/%s/all" % library.provider_key for row in self.client.paged(path, {"type": libtype}): item = self._to_item(row, library) if item is not None: yield item def show_guids(self, library: Library) -> dict[str, str]: """Map show ratingKey -> show guid. Episodes carry grandparentRatingKey but not the show's guid, and keep marks for seasons are keyed on the SHOW's guid (§6.6), so this mapping has to be fetched separately — one cheap request per show library. """ out: dict[str, str] = {} path = "/library/sections/%s/all" % library.provider_key for row in self.client.paged(path, {"type": LIBTYPE_SHOW}): rk = row.get("ratingKey") if rk is not None: out[str(rk)] = row.get("guid") or "" return out @staticmethod def _to_item(row: dict, library: Library) -> Item | None: rk = row.get("ratingKey") if rk is None: return None parts: list[Part] = [] resolution = video_codec = None for media in row.get("Media") or []: if resolution is None: resolution = media.get("videoResolution") video_codec = media.get("videoCodec") for p in media.get("Part") or []: if not p.get("file"): continue parts.append(Part( file_path=p["file"], size_bytes=_int(p.get("size")), provider_part_id=str(p.get("id")) if p.get("id") is not None else None, container=p.get("container") or media.get("container"), resolution=media.get("videoResolution"), video_codec=media.get("videoCodec"), audio_codec=media.get("audioCodec"), bitrate=_opt_int(media.get("bitrate")), )) kind = "movie" if library.kind == "movie" else "episode" # Plex sometimes returns an episode with no parentRatingKey even though # it knows the show and the season number (seen on Firefly in TV Show # Archive: 15 episodes, parentGuid present, parentRatingKey null). # Without a season id the whole season used to be dropped from the # report, so synthesize a stable one from show + season number. season_id = row.get("parentRatingKey") if season_id is None and row.get("grandparentRatingKey") is not None \ and row.get("parentIndex") is not None: season_id = "%s:s%s" % (row["grandparentRatingKey"], row["parentIndex"]) return Item( provider_item_id=str(rk), kind=kind, title=row.get("title") or "(untitled)", library_key=library.provider_key, guid=row.get("guid"), sort_title=row.get("titleSort"), year=_opt_int(row.get("year")), added_at=_opt_int(row.get("addedAt")), updated_at=_opt_int(row.get("updatedAt")), duration_ms=_int(row.get("duration")), view_count=_int(row.get("viewCount")), last_viewed_at=_opt_int(row.get("lastViewedAt")), resolution=resolution, video_codec=video_codec, parts=parts, show_id=str(row["grandparentRatingKey"]) if row.get("grandparentRatingKey") is not None else None, show_title=row.get("grandparentTitle"), season_id=str(season_id) if season_id is not None else None, season_number=_opt_int(row.get("parentIndex")), episode_number=_opt_int(row.get("index")), ) def refresh_library(self, library: Library) -> None: raise NotImplementedError("v1 never writes to Plex (§1.2)") class PlexHistoryProvider: """Fallback HistoryProvider using /status/sessions/history/all. Server-wide across accounts, which solves the token-scoping problem — but it records only that a play happened, never how far it got. Everything it returns is therefore treated as a completed view and the 'rejection' score component drops out of the weighting (§4.11). """ name = "plex" def __init__(self, client: PlexClient): self.client = client @property def has_completion_data(self) -> bool: return False def server_info(self) -> ServerInfo: mc = self.client.get("/identity") return ServerInfo( kind="plex", name=mc.get("friendlyName") or "Plex", version=mc.get("version") or "", server_id=mc.get("machineIdentifier") or "", base_url=self.client.base_url, ) def accounts(self) -> list[Account]: try: mc = self.client.get("/accounts") except ProviderError: return [] out = [] for a in mc.get("Account") or []: if a.get("id") is None: continue out.append(Account(account_id=str(a["id"]), name=a.get("name"))) return out def watch_events(self, since: int | None = None) -> Iterator[WatchEvent]: params: dict = {"sort": "viewedAt:desc"} for row in self.client.paged("/status/sessions/history/all", params): viewed = _opt_int(row.get("viewedAt")) rk = row.get("ratingKey") if viewed is None or rk is None: continue if since is not None and viewed <= since: return # sorted desc: the watermark ends the walk yield WatchEvent( source="plex", source_row_id=str(row.get("historyKey") or f"{rk}:{viewed}"), provider_item_id=str(rk), viewed_at=viewed, account_id=str(row["accountID"]) if row.get("accountID") is not None else None, media_type=row.get("type"), ) def coverage(self) -> Coverage: earliest = latest = None count = 0 for ev in self.watch_events(): count += 1 if latest is None or ev.viewed_at > latest: latest = ev.viewed_at if earliest is None or ev.viewed_at < earliest: earliest = ev.viewed_at return Coverage(earliest, latest, count)