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
305 lines
11 KiB
Python
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)
|