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:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.103
|
||||||
|
uvicorn>=0.49
|
||||||
|
PlexAPI>=4.18
|
||||||
|
pydantic-settings>=2.0
|
||||||
|
requests>=2.31
|
||||||
Reference in New Issue
Block a user