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.
This commit is contained in:
2026-07-09 23:38:31 +02:00
commit 3d6a2008bb
11 changed files with 845 additions and 0 deletions
View File
+15
View File
@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
plex_baseurl: str = "http://host.containers.internal:32400"
plex_token: str = ""
plex_username: str = ""
plex_password: str = ""
log_level: str = "INFO"
trigger_rate_limit: int = 60
schedule_interval_minutes: int = 5
target_content_rating: str = "2"
target_library: str = "BorneVideo"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from app.config import Settings
from app.plex_service import PlexService
logger = logging.getLogger("plex-age-setter")
class RateLimiter:
def __init__(self, max_per_minute: int):
self.max_per_minute = max_per_minute
self._window_start: float = 0
self._count: int = 0
def allow(self) -> bool:
now = time.monotonic()
if now - self._window_start > 60:
self._window_start = now
self._count = 0
if self._count >= self.max_per_minute:
return False
self._count += 1
return True
class AppState:
def __init__(self, settings: Settings):
self.settings = settings
self.plex = PlexService(
baseurl=settings.plex_baseurl,
token=settings.plex_token,
username=settings.plex_username,
password=settings.plex_password,
)
self.trigger_limiter = RateLimiter(settings.trigger_rate_limit)
self._health = True
self._last_run: str | None = None
@property
def last_run(self) -> str | None:
return self._last_run
@last_run.setter
def last_run(self, value: str | None):
self._last_run = value
state: AppState | None = None
# ── MCP JSON-RPC helpers ─────────────────────────────────────
def mcp_error(id_val: int | str | None, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": id_val, "error": {"code": code, "message": message}}
def mcp_result(id_val: int | str | None, result: object) -> dict:
return {"jsonrpc": "2.0", "id": id_val, "result": result}
def handle_mcp_request(body: dict) -> dict:
assert state is not None, "App not initialized"
req_id = body.get("id")
method = body.get("method", "")
params = body.get("params", {})
if method == "tools/list":
tools = PlexService.tool_registry()
return mcp_result(
req_id,
{
"tools": [
{
"name": t.name,
"description": t.description,
"inputSchema": t.inputSchema,
}
for t in tools
]
},
)
if method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
try:
result = state.plex.call_tool(tool_name, arguments)
return mcp_result(
req_id, {"content": [{"type": "text", "text": str(result)}]}
)
except ValueError as e:
return mcp_error(req_id, -32601, str(e))
except Exception as e:
logger.exception("Tool call failed: %s", tool_name)
return mcp_error(req_id, -32000, str(e))
if method == "resources/list":
return mcp_result(req_id, {"resources": []})
return mcp_error(req_id, -32601, f"Method not found: {method}")
# ── FastAPI app ──────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
global state
settings = Settings()
state = AppState(settings)
logger.info("Plex Age Limit Setter started")
async def scheduled_task():
st = state
assert st is not None
while True:
now = datetime.now(timezone.utc).isoformat()
logger.info("Running scheduled task at %s", now)
try:
result = st.plex.run_scheduled_task(
library=settings.target_library,
target_rating=settings.target_content_rating,
)
st.last_run = now
logger.info(
"Scheduled task complete: updated=%d skipped=%d errors=%d",
result.get("updated", 0),
result.get("skipped", 0),
result.get("total_errors", 0),
)
except Exception as e:
logger.exception("Scheduled task failed: %s", e)
await asyncio.sleep(settings.schedule_interval_minutes * 60)
task = asyncio.create_task(scheduled_task())
yield
task.cancel()
logger.info("Plex Age Limit Setter stopped")
app = FastAPI(lifespan=lifespan, title="Plex Age Limit Setter")
@app.get("/health")
def health():
st = state
return {"status": "healthy", "last_run": st.last_run if st else None}
@app.post("/trigger")
def trigger(request: Request):
if state is None or not state.trigger_limiter.allow():
raise HTTPException(status_code=429, detail="Rate limit exceeded (1 req/min)")
try:
result = state.plex.run_scheduled_task(
library=state.settings.target_library,
target_rating=state.settings.target_content_rating,
)
state.last_run = datetime.now(timezone.utc).isoformat()
return result
except Exception as e:
logger.exception("Trigger failed")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/mcp")
async def mcp_endpoint(request: Request):
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": "Parse error"},
},
)
if not isinstance(body, dict) or "method" not in body:
return JSONResponse(
status_code=400,
content={
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Invalid Request"},
},
)
result = handle_mcp_request(body)
return JSONResponse(content=result)
+500
View File
@@ -0,0 +1,500 @@
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)