From 3d6a2008bb92b21a9d6ee5ed50d3b3a060741fce Mon Sep 17 00:00:00 2001 From: Ole Valente Date: Thu, 9 Jul 2026 23:38:31 +0200 Subject: [PATCH] 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. --- .env.example | 10 + .gitignore | 4 + Containerfile | 14 ++ Makefile | 32 +++ README.md | 48 ++++ app/__init__.py | 0 app/config.py | 15 ++ app/main.py | 198 +++++++++++++++ app/plex_service.py | 500 ++++++++++++++++++++++++++++++++++++++ plex-age-setter.container | 19 ++ requirements.txt | 5 + 11 files changed, 845 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/plex_service.py create mode 100644 plex-age-setter.container create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0fec7f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9afb454 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.ruff_cache/ +.env \ No newline at end of file diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..afe017b --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b32ab8 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d4f6ea --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..9360989 --- /dev/null +++ b/app/config.py @@ -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"} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f7f2c16 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/plex_service.py b/app/plex_service.py new file mode 100644 index 0000000..ace7f0c --- /dev/null +++ b/app/plex_service.py @@ -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) diff --git a/plex-age-setter.container b/plex-age-setter.container new file mode 100644 index 0000000..deb2253 --- /dev/null +++ b/plex-age-setter.container @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4dc2b24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.103 +uvicorn>=0.49 +PlexAPI>=4.18 +pydantic-settings>=2.0 +requests>=2.31