MediaShelf/mediashelf/providers/plex.py
Jess Hallsworth d12cbc62ee
Stop dropping seasons Plex reports without a parentRatingKey
The first real scan logged 15 "episode has no season; skipped" warnings. They
are Firefly S1 in TV Show Archive: Plex returns those episodes with
grandparentRatingKey and parentIndex set and parentGuid present, but
parentRatingKey null. Requiring parentRatingKey meant the entire season was
silently absent from the report - exactly the kind of quiet omission a reclaim
tool must not have.

The season key is now synthesized from show + season number when Plex omits it,
which is stable across scans. Keep marks are unaffected either way since they
key on GUIDs, not rating keys.

Also drops the multi_part flag from ordinary seasons. A season has one part per
episode, so part_count > 1 is normal there and the badge appeared on every TV
row; it now means what it says - a movie held more than once, or a season with
more files than episodes.

Both cases are in the fake server now, so the suite covers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
2026-09-10 14:37:30 +00:00

305 lines
11 KiB
Python

"""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)