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
+10
View File
@@ -0,0 +1,10 @@
PLEX_BASEURL=http://host.containers.internal:32400
PLEX_TOKEN=your_plex_token_here
# Alternative to token - use username/password:
# PLEX_USERNAME=ole@example.com
# PLEX_PASSWORD=your_password
LOG_LEVEL=INFO
TRIGGER_RATE_LIMIT=60
SCHEDULE_INTERVAL_MINUTES=5
TARGET_CONTENT_RATING=2
TARGET_LIBRARY=BorneVideo
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.ruff_cache/
.env
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+32
View File
@@ -0,0 +1,32 @@
IMAGE := localhost/plex-age-setter:latest
QUADLET_DEST := /etc/containers/systemd/plex-age-setter.container
.PHONY: build install start stop logs
build:
podman build -t $(IMAGE) -f Containerfile .
install:
sudo cp plex-age-setter.container $(QUADLET_DEST)
sudo systemctl daemon-reload
start:
sudo systemctl start plex-age-setter.service
stop:
sudo systemctl stop plex-age-setter.service
restart:
sudo systemctl restart plex-age-setter.service
status:
sudo systemctl status plex-age-setter.service
logs:
sudo journalctl -u plex-age-setter.service -f
enable:
sudo systemctl enable plex-age-setter.service
disable:
sudo systemctl disable plex-age-setter.service
+48
View File
@@ -0,0 +1,48 @@
# Plex Age Limit Setter
A FastAPI service that automatically sets content ratings on Plex media library items. Runs on a schedule and exposes an MCP (Model Context Protocol) endpoint for AI-agent-driven library management.
## Features
- **Scheduled sync** — Periodically applies a target content rating to all items in a specified library
- **Manual trigger** — `POST /trigger` to run an immediate sync (rate-limited to 1 req/min)
- **MCP tool server** — `POST /mcp` exposes Plex operations as MCP tools for AI agents (list libraries, search movies, set ratings, batch update, manage collections, refresh/analyze libraries)
- **Health endpoint** — `GET /health`
## Configuration
Config via environment variables (`.env` file):
| Variable | Default | Description |
|---|---|---|
| `PLEX_BASEURL` | `http://host.containers.internal:32400` | Plex server URL |
| `PLEX_TOKEN` | — | Plex auth token |
| `PLEX_USERNAME` | — | Plex username (fallback auth) |
| `PLEX_PASSWORD` | — | Plex password (fallback auth) |
| `TARGET_LIBRARY` | `BorneVideo` | Library section to manage |
| `TARGET_CONTENT_RATING` | `2` | Rating to apply to all items |
| `SCHEDULE_INTERVAL_MINUTES` | `5` | How often to run sync |
| `TRIGGER_RATE_LIMIT` | `60` | Max manual triggers per minute |
| `LOG_LEVEL` | `INFO` | Logging level |
## Quick start
```bash
cp .env.example .env # edit with your Plex details
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## Container (Podman Quadlet)
```bash
make build # podman build -t localhost/plex-age-setter:latest
make install # cp quadlet file + systemctl daemon-reload
make start # systemctl start plex-age-setter.service
make stop # systemctl stop plex-age-setter.service
make logs # journalctl -u plex-age-setter.service -f
```
## MCP Tools
The service speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /mcp`. Tools include: `list_libraries`, `get_library_stats`, `list_content_ratings`, `search_movies`, `set_content_rating`, `batch_set_content_rating`, `update_all_content_ratings`, `list_collections`, `refresh_library`, `analyze_library`, and more.
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)
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Plex Age Limit Setter
Documentation=https://github.com/yourusername/plex-age-limit-setter
[Container]
Image=localhost/plex-age-setter:latest
ContainerName=plex-age-setter
EnvironmentFile=.env
Network=host
PublishPort=8000:8000
Label=app=plex-age-setter
HealthCmd=curl -sf http://localhost:8000/health || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
[Install]
WantedBy=default.target
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.103
uvicorn>=0.49
PlexAPI>=4.18
pydantic-settings>=2.0
requests>=2.31