Files
oval 3d6a2008bb initial commit: Plex Age Limit Setter
FastAPI service that syncs content ratings on Plex libraries,
with MCP tool server, scheduled sync, and Podman Quadlet deployment.
2026-07-09 23:38:31 +02:00

501 lines
19 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import requests
import xml.etree.ElementTree as ET
from plexapi.server import PlexServer
logger = logging.getLogger(__name__)
@dataclass
class ToolSpec:
name: str
description: str
inputSchema: dict[str, Any] = field(
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
)
def _parse_plex_datetime(dt_str: str | None) -> str | None:
if not dt_str:
return None
try:
return datetime.fromisoformat(dt_str).isoformat()
except (ValueError, TypeError):
return dt_str
class PlexService:
def __init__(
self, baseurl: str, token: str = "", username: str = "", password: str = ""
):
self.baseurl = baseurl
self._token = token
self._username = username
self._password = password
self._plex: PlexServer | None = None
self._connected = False
def _ensure_connected(self) -> PlexServer:
if self._plex is not None:
return self._plex
token = self._token
if not token and self._username and self._password:
token = self._authenticate()
self._plex = PlexServer(self.baseurl, token)
self._connected = True
return self._plex
def _authenticate(self) -> str:
url = "https://plex.tv/users/sign_in.xml"
headers = {
"X-Plex-Client-Identifier": "PLEXAGE LIMITSETTER V1",
"X-Plex-Product": "Plex Age Limit Setter",
"X-Plex-Version": "1.0",
}
resp = requests.post(
url, auth=(self._username, self._password), headers=headers
)
resp.raise_for_status()
root = ET.fromstring(resp.content)
token = root.get("authenticationToken")
if not token:
raise RuntimeError("Could not retrieve authentication token from Plex.tv")
return token
# ── library introspection ──────────────────────────────────
def list_libraries(self) -> list[dict[str, Any]]:
plex = self._ensure_connected()
return [
{"title": s.title, "type": s.type, "key": s.key, "uuid": s.uuid}
for s in plex.library.sections()
]
def get_library(self, title_or_key: str):
plex = self._ensure_connected()
sec = plex.library.section(title_or_key)
if sec is None:
raise ValueError(f"Library '{title_or_key}' not found")
return sec
def list_content_ratings(self, library: str) -> list[dict[str, Any]]:
self._ensure_connected()
sec = self.get_library(library)
ratings: dict[str, int] = {}
for item in sec.all():
r = item.contentRating or "UNRATED"
ratings[r] = ratings.get(r, 0) + 1
return sorted(
[{"rating": k, "count": v} for k, v in ratings.items()],
key=lambda x: x["count"],
reverse=True,
)
def get_library_stats(self, library: str) -> dict[str, Any]:
self._ensure_connected()
sec = self.get_library(library)
all_items = sec.all()
total = len(all_items)
rated = sum(1 for i in all_items if i.contentRating)
unrated = total - rated
ratings: dict[str, int] = {}
for i in all_items:
r = i.contentRating or "UNRATED"
ratings[r] = ratings.get(r, 0) + 1
return {
"library": library,
"total_items": total,
"rated": rated,
"unrated": unrated,
"rating_distribution": ratings,
}
# ── movie queries ──────────────────────────────────────────
def _movie_to_dict(self, movie) -> dict[str, Any]:
return {
"title": movie.title,
"year": getattr(movie, "year", None),
"rating_key": movie.ratingKey,
"content_rating": movie.contentRating or "",
"studio": getattr(movie, "studio", None),
"summary": getattr(movie, "summary", "")[:200],
"duration_minutes": (
getattr(movie, "duration", 0) // 60_000
if getattr(movie, "duration", None)
else None
),
"added_at": _parse_plex_datetime(str(movie.addedAt))
if hasattr(movie, "addedAt")
else None,
"updated_at": _parse_plex_datetime(str(movie.updatedAt))
if hasattr(movie, "updatedAt")
else None,
"genres": [g.tag for g in getattr(movie, "genres", [])],
"directors": [d.tag for d in getattr(movie, "directors", [])],
"roles": [r.tag for r in getattr(movie, "roles", [])][:5],
}
def get_movie(self, library: str, title: str) -> dict[str, Any] | None:
self._ensure_connected()
try:
movie = self.get_library(library).get(title)
return self._movie_to_dict(movie)
except Exception:
return None
def get_movie_by_key(self, library: str, rating_key: int) -> dict[str, Any] | None:
self._ensure_connected()
try:
movie = self.get_library(library).fetchItem(rating_key)
return self._movie_to_dict(movie)
except Exception:
return None
def search_movies(
self, library: str, query: str, limit: int = 20
) -> list[dict[str, Any]]:
self._ensure_connected()
sec = self.get_library(library)
results = sec.search(query)
return [self._movie_to_dict(m) for m in results[:limit]]
def list_all_movies(
self, library: str, limit: int = 200, offset: int = 0
) -> list[dict[str, Any]]:
self._ensure_connected()
sec = self.get_library(library)
all_items = sec.all()
return [self._movie_to_dict(m) for m in all_items[offset : offset + limit]]
def get_recently_added(self, library: str, count: int = 10) -> list[dict[str, Any]]:
self._ensure_connected()
sec = self.get_library(library)
items = sec.recentlyAdded() if hasattr(sec, "recentlyAdded") else sec.all()
return [self._movie_to_dict(m) for m in items[:count]]
# ── mutation operations ────────────────────────────────────
def set_content_rating(
self, library: str, title: str, rating: str
) -> dict[str, Any]:
self._ensure_connected()
movie = self.get_library(library).get(title)
previous = movie.contentRating or ""
movie.editContentRating(rating)
logger.info("Set %s contentRating from '%s' -> '%s'", title, previous, rating)
return {"title": title, "previous_rating": previous, "new_rating": rating}
def batch_set_content_rating(
self, library: str, titles: list[str], rating: str
) -> list[dict[str, Any]]:
results = []
for title in titles:
try:
result = self.set_content_rating(library, title, rating)
results.append({"title": title, "status": "ok", **result})
except Exception as e:
results.append({"title": title, "status": "error", "error": str(e)})
return results
def update_all_content_ratings(
self, library: str, target_rating: str
) -> dict[str, Any]:
self._ensure_connected()
sec = self.get_library(library)
updated = 0
skipped = 0
errors = []
for video in sec.all():
try:
if video.contentRating != target_rating:
video.editContentRating(target_rating)
updated += 1
else:
skipped += 1
except Exception as e:
errors.append({"title": video.title, "error": str(e)})
logger.info(
"update_all: library=%s target=%s updated=%d skipped=%d errors=%d",
library,
target_rating,
updated,
skipped,
len(errors),
)
return {
"library": library,
"target_rating": target_rating,
"updated": updated,
"skipped": skipped,
"errors": errors[:50],
"total_errors": len(errors),
}
# ── collections ────────────────────────────────────────────
def list_collections(self, library: str) -> list[dict[str, Any]]:
self._ensure_connected()
sec = self.get_library(library)
return [
{
"title": c.title,
"rating_key": c.ratingKey,
"child_count": len(c.children),
}
for c in sec.collections()
]
def get_movie_collections(self, library: str, title: str) -> list[str]:
self._ensure_connected()
movie = self.get_library(library).get(title)
return [c.tag for c in getattr(movie, "collections", [])]
# ── library maintenance ────────────────────────────────────
def refresh_library(self, library: str) -> dict[str, Any]:
self._ensure_connected()
sec = self.get_library(library)
sec.refresh()
logger.info("Refreshed library '%s'", library)
return {"library": library, "status": "refresh triggered"}
def analyze_library(self, library: str) -> dict[str, Any]:
self._ensure_connected()
sec = self.get_library(library)
sec.analyze()
logger.info("Analyzed library '%s'", library)
return {"library": library, "status": "analysis triggered"}
# ── run scheduled task ─────────────────────────────────────
def run_scheduled_task(
self, library: str | None = None, target_rating: str | None = None
) -> dict[str, Any]:
cfg_library = library or "BorneVideo"
cfg_target = target_rating or "2"
logger.info(
"Scheduled task running on library='%s' target='%s'",
cfg_library,
cfg_target,
)
stats_before = self.get_library_stats(cfg_library)
result = self.update_all_content_ratings(cfg_library, cfg_target)
result["stats_before"] = stats_before
return result
# ── MCP tool registry ──────────────────────────────────────
@staticmethod
def tool_registry() -> list[ToolSpec]:
return [
ToolSpec(
"list_libraries",
"List all Plex library sections",
),
ToolSpec(
"get_movie",
"Get details for a single movie by title",
inputSchema={
"type": "object",
"properties": {
"library": {
"type": "string",
"description": "Library section name",
},
"title": {"type": "string", "description": "Movie title"},
},
"required": ["library", "title"],
},
),
ToolSpec(
"get_movie_by_key",
"Get movie details by its Plex rating key",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"rating_key": {"type": "integer"},
},
"required": ["library", "rating_key"],
},
),
ToolSpec(
"search_movies",
"Search for movies by title query",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"query": {"type": "string"},
"limit": {"type": "integer", "default": 20},
},
"required": ["library", "query"],
},
),
ToolSpec(
"list_all_movies",
"List all movies in a library with pagination",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"limit": {"type": "integer", "default": 200},
"offset": {"type": "integer", "default": 0},
},
"required": ["library"],
},
),
ToolSpec(
"get_recently_added",
"Get recently added movies",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"count": {"type": "integer", "default": 10},
},
"required": ["library"],
},
),
ToolSpec(
"list_content_ratings",
"List all content ratings present in a library with counts",
inputSchema={
"type": "object",
"properties": {"library": {"type": "string"}},
"required": ["library"],
},
),
ToolSpec(
"get_library_stats",
"Get statistics about a library (total, rated, unrated, distribution)",
inputSchema={
"type": "object",
"properties": {"library": {"type": "string"}},
"required": ["library"],
},
),
ToolSpec(
"set_content_rating",
"Set the content rating on a single movie",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"title": {"type": "string"},
"rating": {
"type": "string",
"description": "e.g. '2', '7', '11', 'PG-13'",
},
},
"required": ["library", "title", "rating"],
},
),
ToolSpec(
"batch_set_content_rating",
"Set content rating on multiple movies by title",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"titles": {"type": "array", "items": {"type": "string"}},
"rating": {"type": "string"},
},
"required": ["library", "titles", "rating"],
},
),
ToolSpec(
"update_all_content_ratings",
"Update all items in a library to a target content rating",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"target_rating": {"type": "string", "default": "2"},
},
"required": ["library"],
},
),
ToolSpec(
"list_collections",
"List all collections in a library",
inputSchema={
"type": "object",
"properties": {"library": {"type": "string"}},
"required": ["library"],
},
),
ToolSpec(
"get_movie_collections",
"Get collections a movie belongs to",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string"},
"title": {"type": "string"},
},
"required": ["library", "title"],
},
),
ToolSpec(
"refresh_library",
"Trigger a library refresh (rescan)",
inputSchema={
"type": "object",
"properties": {"library": {"type": "string"}},
"required": ["library"],
},
),
ToolSpec(
"analyze_library",
"Trigger a library analysis",
inputSchema={
"type": "object",
"properties": {"library": {"type": "string"}},
"required": ["library"],
},
),
ToolSpec(
"run_scheduled_task",
"Run the standard scheduled task: update all items to target rating",
inputSchema={
"type": "object",
"properties": {
"library": {"type": "string", "default": "BorneVideo"},
"target_rating": {"type": "string", "default": "2"},
},
},
),
]
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
method_map = {
"list_libraries": lambda args: self.list_libraries(),
"get_movie": self.get_movie,
"get_movie_by_key": self.get_movie_by_key,
"search_movies": self.search_movies,
"list_all_movies": self.list_all_movies,
"get_recently_added": self.get_recently_added,
"list_content_ratings": self.list_content_ratings,
"get_library_stats": self.get_library_stats,
"set_content_rating": self.set_content_rating,
"batch_set_content_rating": self.batch_set_content_rating,
"update_all_content_ratings": self.update_all_content_ratings,
"list_collections": self.list_collections,
"get_movie_collections": self.get_movie_collections,
"refresh_library": self.refresh_library,
"analyze_library": self.analyze_library,
"run_scheduled_task": self.run_scheduled_task,
}
fn = method_map.get(name)
if fn is None:
raise ValueError(f"Unknown tool: {name}")
return fn(**arguments)