972 lines
37 KiB
Python
972 lines
37 KiB
Python
"""
|
|
Seerr skill — connects to Overseerr / Jellyseerr API for media discovery,
|
|
requests, and issue submission.
|
|
|
|
.env variables:
|
|
SEERR_URL - base URL (e.g. https://seerr.example.com)
|
|
SEERR_USERNAME - login username (email)
|
|
SEERR_PASSWORD - login password
|
|
SEERR_API_KEY - fallback API key (used if username/password not set)
|
|
SEERR_TIMEOUT - optional request timeout in seconds (default 30)
|
|
|
|
Auth flow:
|
|
1. If SEERR_USERNAME + SEERR_PASSWORD are set:
|
|
POST /api/v1/auth/jellyfin {username, password}
|
|
→ stores the connect.sid cookie in a persistent httpx session
|
|
→ all subsequent requests use cookie auth
|
|
2. Falls back to X-Api-Key header if SEERR_API_KEY is set.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
|
|
from agents.skills import Skill, register, ToolResult, get_config
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
SEERR_URL = (get_config("SEERR_URL") or "").rstrip("/")
|
|
SEERR_USERNAME = get_config("SEERR_USERNAME") or ""
|
|
SEERR_PASSWORD = get_config("SEERR_PASSWORD") or ""
|
|
SEERR_API_KEY = get_config("SEERR_API_KEY") or ""
|
|
SEERR_TIMEOUT = int(get_config("SEERR_TIMEOUT", "30"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth — cookie-based session (preferred) or API key fallback
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# IMPORTANT: httpx.AsyncClient binds internal asyncio primitives to the
|
|
# event loop that is current when the client is created. The Discord bot
|
|
# runs in a separate thread with its own event loop, so we must create a
|
|
# fresh AsyncClient *per event loop*. We cache one client per loop ID so
|
|
# each loop still reuses its own singleton (connection pooling works), but
|
|
# the bot and the REST API never fight over the same connection pool.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
_seerr_sessions: dict[int, httpx.AsyncClient] = {}
|
|
_seerr_sessions_lock = threading.Lock()
|
|
|
|
# Cached login cookies — obtained once at module load (sync) and reused
|
|
# for every event-loop-specific client. A threading.Event ensures that
|
|
# the first caller to trigger the login blocks all other callers until
|
|
# the login is complete — preventing a race where a second thread builds
|
|
# a client with empty cookies before the login finishes.
|
|
_seerr_cookies: dict = {}
|
|
_seerr_cookies_ready = threading.Event()
|
|
_seerr_cookies_lock = threading.Lock()
|
|
|
|
|
|
def _ensure_cookies() -> None:
|
|
"""One-time sync login to get the connect.sid cookie.
|
|
|
|
Thread-safe: only one thread performs the login; all others block
|
|
until it finishes, then reuse the result.
|
|
"""
|
|
if _seerr_cookies_ready.is_set():
|
|
return
|
|
|
|
with _seerr_cookies_lock:
|
|
# Double-check — another thread may have finished while we waited
|
|
if _seerr_cookies_ready.is_set():
|
|
return
|
|
|
|
if SEERR_USERNAME.strip() and SEERR_PASSWORD.strip():
|
|
sync_client = httpx.Client(base_url=SEERR_URL, timeout=SEERR_TIMEOUT)
|
|
try:
|
|
resp = sync_client.post("/api/v1/auth/jellyfin", json={
|
|
"username": SEERR_USERNAME.strip(),
|
|
"password": SEERR_PASSWORD.strip(),
|
|
})
|
|
resp.raise_for_status()
|
|
_seerr_cookies.update(dict(sync_client.cookies))
|
|
except httpx.HTTPError:
|
|
pass
|
|
finally:
|
|
sync_client.close()
|
|
|
|
# Signal completion — even if login failed (empty cookies) so we
|
|
# don't retry forever.
|
|
_seerr_cookies_ready.set()
|
|
|
|
|
|
def _build_client() -> httpx.AsyncClient:
|
|
"""Create a new httpx.AsyncClient for the *current* event loop."""
|
|
if _seerr_cookies:
|
|
return httpx.AsyncClient(
|
|
base_url=SEERR_URL,
|
|
cookies=_seerr_cookies,
|
|
timeout=SEERR_TIMEOUT,
|
|
)
|
|
if SEERR_API_KEY.strip():
|
|
return httpx.AsyncClient(
|
|
base_url=SEERR_URL,
|
|
headers={"X-Api-Key": SEERR_API_KEY.strip()},
|
|
timeout=SEERR_TIMEOUT,
|
|
)
|
|
return httpx.AsyncClient(
|
|
base_url=SEERR_URL,
|
|
timeout=SEERR_TIMEOUT,
|
|
)
|
|
|
|
|
|
def _get_session() -> httpx.AsyncClient:
|
|
"""Return an AsyncClient valid for the currently-running event loop.
|
|
|
|
On the very first call the sync login is performed (if credentials are
|
|
configured). After that every event loop gets its own cached client.
|
|
"""
|
|
_ensure_cookies()
|
|
|
|
try:
|
|
loop_id = id(asyncio.get_running_loop())
|
|
except RuntimeError:
|
|
# No event loop running (e.g. called during module import).
|
|
# Build a throw-away client — the first real call will recreate it.
|
|
loop_id = 0
|
|
|
|
with _seerr_sessions_lock:
|
|
if loop_id not in _seerr_sessions:
|
|
_seerr_sessions[loop_id] = _build_client()
|
|
return _seerr_sessions[loop_id]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _SharedClient:
|
|
"""Wraps a per-loop httpx.AsyncClient so that `async with` doesn't
|
|
close it when the context exits. All 11 call sites use:
|
|
async with _client() as c:
|
|
"""
|
|
|
|
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
self._client = client
|
|
|
|
async def __aenter__(self) -> httpx.AsyncClient:
|
|
return self._client
|
|
|
|
async def __aexit__(self, *args: object) -> None:
|
|
pass # do NOT close the shared session
|
|
|
|
|
|
def _client() -> _SharedClient:
|
|
"""Return a context-manager wrapper around the current loop's session."""
|
|
return _SharedClient(_get_session())
|
|
|
|
|
|
# Per-loop sessions are created lazily on first use — no eager init needed.
|
|
|
|
|
|
def _fmt_items(items: list[dict], kind: str) -> str:
|
|
"""Format a list of media items for the LLM to present.
|
|
Includes the TMDb ID so the LLM can reference it for follow-up actions."""
|
|
lines = []
|
|
for i, item in enumerate(items[:10], 1):
|
|
title = item.get("title") or item.get("name") or "Unknown"
|
|
year = (
|
|
item.get("releaseDate", "")[:4]
|
|
or item.get("firstAirDate", "")[:4]
|
|
or "?"
|
|
)
|
|
tmdb_id = item.get("id", "")
|
|
overview = (item.get("overview") or "")[:120]
|
|
id_tag = f" [tmdb:{tmdb_id}]" if tmdb_id else ""
|
|
lines.append(f"{i}. **{title}** ({year}){id_tag} — {overview}…")
|
|
return f"Found {len(items)} {kind}. Top results:\n\n" + "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool definitions (OpenAI function-calling schema)
|
|
# ---------------------------------------------------------------------------
|
|
TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_trending",
|
|
"description": "Get trending movies and TV shows from Seerr using "
|
|
"the /discover/trending endpoint. Call this when a user asks what "
|
|
"is popular, trending, or new.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv", "all"],
|
|
"description": "What kind of media to fetch. "
|
|
"Use 'all' when the user doesn't specify.",
|
|
},
|
|
"language": {
|
|
"type": "string",
|
|
"description": "Language filter (e.g. 'en', 'nl'). "
|
|
"Omit for all languages.",
|
|
},
|
|
},
|
|
"required": ["kind"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_discover",
|
|
"description": "Discover movies or TV shows by genre, studio, "
|
|
"keyword, or language in Seerr. Uses /discover/{movies|tv}/genre/{id} "
|
|
"for genre queries, /discover/{movies|tv}/studio/{id} for studios, "
|
|
"and /discover/{movies|tv}?query= for keyword search. "
|
|
"Call when a user asks 'what movies in category X do you recommend?' "
|
|
"or 'show me horror movies' or 'find Studio Ghibli movies'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv"],
|
|
"description": "Media type to search.",
|
|
},
|
|
"genre": {
|
|
"type": "string",
|
|
"description": "Genre name, e.g. 'horror', 'comedy', "
|
|
"'animation', 'action', 'science fiction'. "
|
|
"Use this for genre-based discovery.",
|
|
},
|
|
"studio": {
|
|
"type": "string",
|
|
"description": "Studio name to filter by, e.g. "
|
|
"'Studio Ghibli', 'Pixar', 'Marvel'. "
|
|
"Use this for studio-based discovery.",
|
|
},
|
|
"keyword": {
|
|
"type": "string",
|
|
"description": "Free-text keyword search, e.g. "
|
|
"'space', 'superhero', 'dinosaur'. "
|
|
"Use this for topic-based discovery.",
|
|
},
|
|
"language": {
|
|
"type": "string",
|
|
"description": "Language filter (e.g. 'en', 'ja'). "
|
|
"Omit for all languages.",
|
|
},
|
|
"page": {
|
|
"type": "integer",
|
|
"description": "Page number (default 1).",
|
|
},
|
|
},
|
|
"required": ["kind"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_request_media",
|
|
"description": "Request a movie or TV show to be added to the media "
|
|
"library via Seerr. Call when a user asks 'can you request movie X?' "
|
|
"or 'please add show Y'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv"],
|
|
"description": "Whether this is a movie or TV show.",
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "The title of the movie or TV show to request.",
|
|
},
|
|
"tmdb_id": {
|
|
"type": "integer",
|
|
"description": "The TMDb ID if known (optional — Seerr will "
|
|
"search by title if not provided).",
|
|
},
|
|
},
|
|
"required": ["kind", "title"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_search",
|
|
"description": "Search for movies, TV shows, or people on Seerr "
|
|
"by title or name. Uses /search. Call when a user asks 'find me "
|
|
"the movie X', 'search for show Y', or 'who is actor Z?'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {
|
|
"type": "string",
|
|
"description": "The search term — a movie title, "
|
|
"TV show name, or person name.",
|
|
},
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv", "person", "all"],
|
|
"description": "Filter by media type. Use 'all' "
|
|
"when the user doesn't specify.",
|
|
},
|
|
"language": {
|
|
"type": "string",
|
|
"description": "Language filter (e.g. 'en'). "
|
|
"Omit for all languages.",
|
|
},
|
|
"page": {
|
|
"type": "integer",
|
|
"description": "Page number (default 1).",
|
|
},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_media_details",
|
|
"description": "Get full details for a specific movie or TV show "
|
|
"(cast, crew, runtime, genres, ratings, streaming providers, etc.). "
|
|
"Call when a user asks 'tell me about movie X' or 'show me details "
|
|
"for show Y'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv"],
|
|
"description": "Whether to look up a movie or TV show.",
|
|
},
|
|
"tmdb_id": {
|
|
"type": "integer",
|
|
"description": "The TMDb ID of the movie or TV show.",
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "Title to search for if tmdb_id is "
|
|
"not known. The system will search and use the first match.",
|
|
},
|
|
"language": {
|
|
"type": "string",
|
|
"description": "Language filter (e.g. 'en'). "
|
|
"Omit for all languages.",
|
|
},
|
|
},
|
|
"required": ["kind"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_my_requests",
|
|
"description": "Get the user's pending, approved, or completed "
|
|
"media requests from Seerr. Call when a user asks 'what have I "
|
|
"requested?', 'status of my requests?', or 'did my request go through?'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"filter": {
|
|
"type": "string",
|
|
"enum": ["all", "approved", "available", "pending",
|
|
"processing", "unavailable", "failed",
|
|
"deleted", "completed"],
|
|
"description": "Filter by request status. "
|
|
"Default is 'pending'.",
|
|
},
|
|
"media_type": {
|
|
"type": "string",
|
|
"enum": ["movie", "tv", "all"],
|
|
"description": "Filter by media type. "
|
|
"Default is 'all'.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "seerr_submit_issue",
|
|
"description": "Submit a ticket/issue for a specific media item. "
|
|
"Call when a user wants to report a problem (bad quality, wrong "
|
|
"language, missing episodes, corrupt file, etc.) or when they want "
|
|
"an action that only a human operator can perform. "
|
|
"IMPORTANT: always include the media_title so the system can "
|
|
"look up the correct mediaId.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"subject": {
|
|
"type": "string",
|
|
"description": "Short summary of the issue.",
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Detailed description of the problem.",
|
|
},
|
|
"media_title": {
|
|
"type": "string",
|
|
"description": "The movie or TV show title this issue "
|
|
"relates to. Always provide this — the system will "
|
|
"search for the matching mediaId.",
|
|
},
|
|
"issue_type": {
|
|
"type": "integer",
|
|
"enum": [1, 2, 3, 4],
|
|
"description": "Issue category code: "
|
|
"1 = Video (playback, codec, quality), "
|
|
"2 = Audio (sync, missing), "
|
|
"3 = Subtitle (missing, wrong, timing), "
|
|
"4 = Other (operator-only actions like delete/cancel).",
|
|
},
|
|
},
|
|
"required": ["subject", "description", "media_title", "issue_type"],
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool executor
|
|
# ---------------------------------------------------------------------------
|
|
async def _execute(tool_name: str, args: dict) -> ToolResult:
|
|
"""Route tool calls to the right handler. Returns ToolResult with success
|
|
based on HTTP status code (2xx = ok, everything else = fail)."""
|
|
import logging
|
|
logger = logging.getLogger("skills.seerr")
|
|
|
|
handlers = {
|
|
"seerr_trending": _trending,
|
|
"seerr_discover": _discover,
|
|
"seerr_request_media": _request_media,
|
|
"seerr_submit_issue": _submit_issue,
|
|
"seerr_search": _search,
|
|
"seerr_media_details": _media_details,
|
|
"seerr_my_requests": _my_requests,
|
|
}
|
|
handler = handlers.get(tool_name)
|
|
if not handler:
|
|
return ToolResult.fail(f"Unknown tool: {tool_name}")
|
|
|
|
logger.info(
|
|
"🔧 TOOL CALL: %s | args=%s",
|
|
tool_name,
|
|
{k: v for k, v in args.items() if k not in ("description",)},
|
|
)
|
|
|
|
try:
|
|
result = await handler(args)
|
|
status = "✅" if result.success else "❌"
|
|
logger.info(
|
|
"%s TOOL RESULT: %s → %.300s",
|
|
status, tool_name, result.content,
|
|
)
|
|
return result
|
|
except httpx.HTTPStatusError as exc:
|
|
status = exc.response.status_code
|
|
body = exc.response.text[:500]
|
|
logger.error("Seerr API HTTP %s on %s: %s", status, tool_name, body)
|
|
return ToolResult.fail(
|
|
f"Seerr API returned HTTP {status} for '{tool_name}'. "
|
|
f"Response: {body}"
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
logger.error("Seerr API network error on %s: %s", tool_name, exc)
|
|
return ToolResult.fail(
|
|
f"Seerr API is unreachable for '{tool_name}': {exc}"
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("Unexpected error in %s", tool_name)
|
|
return ToolResult.fail(f"Unexpected error in '{tool_name}': {exc}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _trending(args: dict) -> ToolResult:
|
|
"""Use Jellyseerr's /api/v1/discover/trending endpoint.
|
|
Query params: language (optional), mediaType (movie | tv, default all).
|
|
"""
|
|
media_type = args.get("kind", "all")
|
|
language = args.get("language", "").strip() or None
|
|
|
|
params: dict = {}
|
|
if media_type in ("movie", "tv"):
|
|
params["mediaType"] = media_type
|
|
if language:
|
|
params["language"] = language
|
|
|
|
async with _client() as c:
|
|
r = await c.get("/api/v1/discover/trending", params=params)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
results = data.get("results", [])
|
|
|
|
label = f"trending {media_type}" if media_type != "all" else "trending items"
|
|
if language:
|
|
label += f" ({language})"
|
|
if not results:
|
|
return ToolResult.ok(f"No {label} found right now.")
|
|
return ToolResult.ok(_fmt_items(results, label))
|
|
|
|
|
|
async def _discover(args: dict) -> ToolResult:
|
|
"""Jellyseerr discover endpoints:
|
|
Genre: /api/v1/discover/{movies|tv}/genre/{genreId}
|
|
Keyword: /api/v1/search?query=keyword (free-text search, filtered by mediaType)
|
|
Studio: NOT SUPPORTED — /discover/studio requires a numeric TMDB studio ID,
|
|
not a name. Use keyword search as fallback.
|
|
|
|
Language is passed to discover or search as appropriate.
|
|
"""
|
|
kind = args["kind"]
|
|
genre = args.get("genre", "").strip()
|
|
studio = args.get("studio", "").strip()
|
|
keyword = args.get("keyword", "").strip()
|
|
language = args.get("language", "").strip() or None
|
|
page = args.get("page", 1)
|
|
|
|
# Map common genre names to TMDb genre IDs
|
|
genre_map = {
|
|
"action": 28, "adventure": 12, "animation": 16, "comedy": 35,
|
|
"crime": 80, "documentary": 99, "drama": 18, "family": 10751,
|
|
"fantasy": 14, "history": 36, "horror": 27, "music": 10402,
|
|
"mystery": 9648, "romance": 10749, "science fiction": 878,
|
|
"sci-fi": 878, "scifi": 878, "tv movie": 10770, "thriller": 53,
|
|
"war": 10752, "western": 37,
|
|
}
|
|
|
|
params: dict = {"page": page}
|
|
endpoint: str
|
|
|
|
if genre:
|
|
genre_id = genre_map.get(genre.lower())
|
|
if not genre_id:
|
|
return ToolResult.fail(
|
|
f"I don't recognise the genre '{genre}'. "
|
|
f"Try one of: {', '.join(sorted(genre_map.keys()))}."
|
|
)
|
|
endpoint = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}/genre/{genre_id}"
|
|
if language:
|
|
params["language"] = language
|
|
async with _client() as c:
|
|
r = await c.get(endpoint, params=params)
|
|
r.raise_for_status()
|
|
results = r.json().get("results", [])
|
|
desc = genre
|
|
elif studio:
|
|
# /discover/studio/{studioId} requires a numeric TMDB studio ID.
|
|
# Fall back to searching by name via /search.
|
|
desc = studio
|
|
search_query = studio
|
|
endpoint = "/api/v1/search"
|
|
params["query"] = search_query
|
|
if language:
|
|
params["language"] = language
|
|
async with _client() as c:
|
|
r = await c.get(endpoint, params=params)
|
|
r.raise_for_status()
|
|
results = r.json().get("results", [])
|
|
# Filter to requested media type
|
|
results = [item for item in results if item.get("mediaType") == kind]
|
|
elif keyword:
|
|
# Free-text keyword → use /search, filtered by mediaType
|
|
desc = keyword
|
|
endpoint = "/api/v1/search"
|
|
params["query"] = keyword
|
|
if language:
|
|
params["language"] = language
|
|
async with _client() as c:
|
|
r = await c.get(endpoint, params=params)
|
|
r.raise_for_status()
|
|
results = r.json().get("results", [])
|
|
results = [item for item in results if item.get("mediaType") == kind]
|
|
else:
|
|
# Bare discover with no filter
|
|
endpoint = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
|
|
if language:
|
|
params["language"] = language
|
|
async with _client() as c:
|
|
r = await c.get(endpoint, params=params)
|
|
r.raise_for_status()
|
|
results = r.json().get("results", [])
|
|
desc = kind
|
|
|
|
if not results:
|
|
return ToolResult.ok(f"No {desc} {kind}s found.")
|
|
return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s"))
|
|
|
|
|
|
async def _request_media(args: dict) -> ToolResult:
|
|
kind = args["kind"]
|
|
title = args["title"]
|
|
tmdb_id = args.get("tmdb_id")
|
|
|
|
async with _client() as c:
|
|
# --- Fast-path: TMDb ID known — confirm the title and request directly ---
|
|
if tmdb_id:
|
|
# Quick lookup to get the correct title for the confirmation message
|
|
detail_r = await c.get(f"/api/v1/{kind}/{tmdb_id}")
|
|
if detail_r.status_code == 200:
|
|
detail = detail_r.json()
|
|
media_title = detail.get("title") or detail.get("name") or title
|
|
media_year = (
|
|
detail.get("releaseDate", "")[:4]
|
|
or detail.get("firstAirDate", "")[:4]
|
|
or "?"
|
|
)
|
|
else:
|
|
# Detail lookup failed — fall back to title search
|
|
pass
|
|
|
|
if detail_r.status_code == 200:
|
|
# Submit directly with the known TMDb ID
|
|
request_body: dict = {"mediaType": kind, "mediaId": tmdb_id}
|
|
if kind == "tv":
|
|
request_body["seasons"] = "all"
|
|
req_r = await c.post("/api/v1/request", json=request_body)
|
|
if req_r.status_code == 201:
|
|
return ToolResult.ok(
|
|
f"✅ Successfully requested **{media_title}** ({media_year}). "
|
|
f"It has been submitted to Seerr and will be processed soon."
|
|
)
|
|
elif req_r.status_code == 409:
|
|
return ToolResult.fail(
|
|
f"⚠️ **{media_title}** ({media_year}) has already been "
|
|
f"requested or is already available."
|
|
)
|
|
else:
|
|
return ToolResult.fail(
|
|
f"❌ Failed to request **{media_title}** ({media_year}). "
|
|
f"Seerr responded with status {req_r.status_code}: {req_r.text[:500]}"
|
|
)
|
|
|
|
# --- Slow-path: search by title ---
|
|
r = await c.get("/api/v1/search", params={"query": quote(title), "page": 1})
|
|
r.raise_for_status()
|
|
results = r.json().get("results", [])
|
|
|
|
# Filter by mediaType (search returns mixed movie/tv/person results)
|
|
filtered = [item for item in results if item.get("mediaType") == kind] if results else []
|
|
|
|
if not filtered:
|
|
return ToolResult.fail(
|
|
f"I couldn't find '{title}' on Seerr. "
|
|
f"Please double-check the title or provide a TMDb ID."
|
|
)
|
|
|
|
# --- Ambiguity check: more than one match? ---
|
|
if len(filtered) > 1:
|
|
lines = [
|
|
f"⚠️ Multiple matches for \"{title}\". "
|
|
f"Please call `seerr_request_media` again with the "
|
|
f"correct `tmdb_id` and exact title:\n"
|
|
]
|
|
for i, item in enumerate(filtered[:10], 1):
|
|
t = item.get("title") or item.get("name", "Unknown")
|
|
y = (
|
|
item.get("releaseDate", "")[:4]
|
|
or item.get("firstAirDate", "")[:4]
|
|
or "?"
|
|
)
|
|
mid = item.get("id", "?")
|
|
lines.append(
|
|
f"{i}. **{t}** ({y}) — `kind=\"{kind}\", "
|
|
f"title=\"{t}\", tmdb_id={mid}`"
|
|
)
|
|
return ToolResult.ok("\n".join(lines))
|
|
|
|
# --- Single match — request it ---
|
|
match = filtered[0]
|
|
media_id = match.get("id")
|
|
media_title = match.get("title") or match.get("name") or title
|
|
media_year = (
|
|
(match.get("releaseDate") or match.get("firstAirDate") or "?")[:4]
|
|
)
|
|
|
|
request_body = {
|
|
"mediaType": kind,
|
|
"mediaId": media_id,
|
|
}
|
|
if kind == "tv":
|
|
request_body["seasons"] = "all"
|
|
|
|
req_r = await c.post("/api/v1/request", json=request_body)
|
|
|
|
if req_r.status_code == 201:
|
|
return ToolResult.ok(
|
|
f"✅ Successfully requested **{media_title}** ({media_year}). "
|
|
f"It has been submitted to Seerr and will be processed soon."
|
|
)
|
|
elif req_r.status_code == 409:
|
|
return ToolResult.fail(
|
|
f"⚠️ **{media_title}** ({media_year}) has already been requested "
|
|
f"or is already available."
|
|
)
|
|
else:
|
|
detail = req_r.text
|
|
return ToolResult.fail(
|
|
f"❌ Failed to request **{media_title}** ({media_year}). "
|
|
f"Seerr responded with status {req_r.status_code}: {detail}"
|
|
)
|
|
|
|
|
|
async def _search(args: dict) -> ToolResult:
|
|
"""Use Jellyseerr's /api/v1/search endpoint.
|
|
Supports filtering by mediaType (movie | tv | person).
|
|
"""
|
|
query = args["query"]
|
|
kind = args.get("kind", "all")
|
|
language = args.get("language", "").strip() or None
|
|
page = args.get("page", 1)
|
|
|
|
params: dict = {"query": quote(query), "page": page}
|
|
if language:
|
|
params["language"] = language
|
|
|
|
async with _client() as c:
|
|
r = await c.get("/api/v1/search", params=params)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
results = data.get("results", [])
|
|
|
|
# Filter by mediaType if requested
|
|
if kind != "all":
|
|
results = [item for item in results if item.get("mediaType") == kind]
|
|
|
|
label = f"search results for '{query}'"
|
|
if kind != "all":
|
|
label += f" ({kind})"
|
|
if not results:
|
|
return ToolResult.ok(f"No {label} found.")
|
|
return ToolResult.ok(_fmt_items(results, label))
|
|
|
|
|
|
async def _media_details(args: dict) -> ToolResult:
|
|
"""Fetch full details for a movie or TV show.
|
|
Resolves the TMDb ID via search if not provided.
|
|
"""
|
|
kind = args["kind"]
|
|
tmdb_id = args.get("tmdb_id")
|
|
title = args.get("title", "").strip()
|
|
language = args.get("language", "").strip() or None
|
|
|
|
params: dict = {}
|
|
if language:
|
|
params["language"] = language
|
|
|
|
async with _client() as c:
|
|
# Resolve TMDb ID if needed
|
|
if not tmdb_id and title:
|
|
sr = await c.get("/api/v1/search", params={
|
|
"query": quote(title), "page": 1,
|
|
})
|
|
sr.raise_for_status()
|
|
sresults = sr.json().get("results", [])
|
|
sresults = [item for item in sresults if item.get("mediaType") == kind]
|
|
if sresults:
|
|
tmdb_id = sresults[0].get("id")
|
|
else:
|
|
return ToolResult.fail(
|
|
f"I couldn't find {kind} '{title}' on Seerr."
|
|
)
|
|
|
|
if not tmdb_id:
|
|
return ToolResult.fail(
|
|
"I need either a TMDb ID or a title to look up media details."
|
|
)
|
|
|
|
endpoint = f"/api/v1/{kind}/{tmdb_id}"
|
|
r = await c.get(endpoint, params=params)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
|
|
# Build a concise summary for the LLM
|
|
title_str = data.get("title") or data.get("name") or "Unknown"
|
|
year = (
|
|
data.get("releaseDate", "")[:4]
|
|
or data.get("firstAirDate", "")[:4]
|
|
or "?"
|
|
)
|
|
overview = data.get("overview", "No overview available.")
|
|
runtime = data.get("runtime", "?")
|
|
vote = data.get("voteAverage", "?")
|
|
genres = ", ".join(g.get("name", "") for g in data.get("genres", [])[:5])
|
|
|
|
lines = [
|
|
f"**{title_str}** ({year}) [tmdb:{tmdb_id}]",
|
|
f"⭐ {vote}/10 | ⏱ {runtime} min | Genres: {genres or 'N/A'}",
|
|
f"",
|
|
f"{overview[:500]}",
|
|
]
|
|
|
|
# Cast (top 5)
|
|
cast = (data.get("credits", {}) or {}).get("cast", [])[:5]
|
|
if cast:
|
|
lines.append("")
|
|
lines.append("**Top Cast:** " + ", ".join(
|
|
c["name"] for c in cast
|
|
))
|
|
|
|
# Streaming providers
|
|
providers = data.get("watchProviders", [])
|
|
if providers:
|
|
flatrate = []
|
|
for region in providers:
|
|
for p in region.get("flatrate", []) or []:
|
|
if p.get("name") and p["name"] not in flatrate:
|
|
flatrate.append(p["name"])
|
|
if flatrate:
|
|
lines.append("")
|
|
lines.append("**Streaming:** " + ", ".join(flatrate[:5]))
|
|
|
|
return ToolResult.ok("\n".join(lines))
|
|
|
|
|
|
async def _my_requests(args: dict) -> ToolResult:
|
|
"""Fetch the current user's media requests from /request.
|
|
Filters and sorting are optional.
|
|
"""
|
|
filter_status = args.get("filter", "pending")
|
|
media_type = args.get("media_type", "all")
|
|
|
|
params: dict = {"filter": filter_status}
|
|
if media_type != "all":
|
|
params["mediaType"] = media_type
|
|
|
|
async with _client() as c:
|
|
r = await c.get("/api/v1/request", params=params)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
results = data.get("results", [])
|
|
|
|
if not results:
|
|
return ToolResult.ok(f"You have no {filter_status} requests right now.")
|
|
|
|
lines = []
|
|
for i, req in enumerate(results[:10], 1):
|
|
media = req.get("media", {}) or {}
|
|
title = media.get("title") or media.get("name") or "Unknown"
|
|
status = req.get("status", "?")
|
|
status_labels = {1: "Pending", 2: "Approved", 3: "Declined"}
|
|
status_str = status_labels.get(status, f"Status {status}")
|
|
is_4k = " (4K)" if req.get("is4k") else ""
|
|
lines.append(f"{i}. **{title}**{is_4k} — {status_str}")
|
|
|
|
total = data.get("pageInfo", {}).get("results", len(results))
|
|
return ToolResult.ok(
|
|
f"You have {total} {filter_status} requests:\n\n" + "\n".join(lines)
|
|
)
|
|
|
|
|
|
async def _submit_issue(args: dict) -> ToolResult:
|
|
subject = args["subject"]
|
|
description = args["description"]
|
|
media_title = args.get("media_title", "")
|
|
issue_type = args.get("issue_type", 4) # numeric code: 1=video, 2=audio, 3=sub, 4=other
|
|
media_id = args.get("media_id")
|
|
|
|
import logging
|
|
logger = logging.getLogger("skills.seerr")
|
|
|
|
body: dict = {
|
|
"issueType": int(issue_type),
|
|
"message": description,
|
|
}
|
|
if media_title:
|
|
body["message"] = f"[Media: {media_title}]\n\n{description}"
|
|
|
|
logger.info("📝 SUBMIT_ISSUE body=%s media_title=%s", body, media_title)
|
|
|
|
async with _client() as c:
|
|
# --- Resolve mediaId (Seerr internal ID for /issue endpoint) ---
|
|
if not media_id and media_title:
|
|
search_r = await c.get("/api/v1/search", params={
|
|
"query": quote(media_title), "page": 1,
|
|
})
|
|
search_r.raise_for_status()
|
|
results = search_r.json().get("results", [])
|
|
|
|
logger.info("🔍 Search for '%s' → %d results", media_title, len(results))
|
|
|
|
# Filter to actual media (not persons) and prefer exact title match
|
|
media_results = [
|
|
item for item in results
|
|
if item.get("mediaType") in ("movie", "tv")
|
|
]
|
|
if media_results:
|
|
media_info = media_results[0].get("mediaInfo", {})
|
|
media_id = media_info.get("id") or media_results[0].get("id")
|
|
logger.info("🔍 Resolved mediaId=%s for '%s'", media_id, media_title)
|
|
|
|
if media_id:
|
|
body["mediaId"] = int(media_id)
|
|
|
|
logger.info("📤 POST /api/v1/issue body=%s", body)
|
|
r = await c.post("/api/v1/issue", json=body)
|
|
logger.info("📥 Response status=%s body=%s", r.status_code, r.text[:500])
|
|
|
|
resp_json = r.json() if r.text else {}
|
|
if r.status_code in (200, 201):
|
|
ticket_id = resp_json.get("id", "N/A")
|
|
return ToolResult.ok(
|
|
f"✅ Issue submitted successfully (ticket #{ticket_id}). "
|
|
f"A human operator will review: **{subject}**"
|
|
)
|
|
else:
|
|
return ToolResult.fail(
|
|
f"❌ Failed to submit issue. Seerr responded with "
|
|
f"status {r.status_code}: {r.text[:500]}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Register the skill
|
|
# ---------------------------------------------------------------------------
|
|
seerr_skill = Skill(
|
|
name="seerr",
|
|
description="Seerr integration — search, trending, discover, request media, "
|
|
"look up details, check requests, submit issues",
|
|
prompt_fragment="""## Seerr Media Tools
|
|
|
|
You have access to the Seerr media management system. Use the provided tools
|
|
to help users with media-related tasks:
|
|
|
|
- **seerr_search** — when a user wants to find a specific movie, show, or person
|
|
- **seerr_trending** — when a user asks what is trending/popular/new
|
|
- **seerr_discover** — when a user asks for recommendations by genre/category
|
|
- **seerr_media_details** — when a user wants full info about a movie or show
|
|
- **seerr_my_requests** — when a user asks about their pending/approved requests
|
|
- **seerr_request_media** — when a user wants to request a movie or TV show
|
|
- **seerr_submit_issue** — when a user needs to report a problem or needs an
|
|
operator-only action (like deleting media or cancelling a request)
|
|
|
|
**TMDb ID Rule**: Every movie and TV show has a unique TMDb ID. When you see
|
|
`[tmdb:123456]` in search/trending/discover results, always **show it to the user**
|
|
in your response. Never strip or omit the TMDb ID when presenting results — the
|
|
user needs it to reference items for follow-up actions. Similarly, capture the ID
|
|
for any follow-up action you take (request details, submit a request, file an
|
|
issue, etc.). If you don't have a TMDb ID and need to take action on a title,
|
|
search first to get one. Never rely on title alone when an ID is available —
|
|
titles are ambiguous, IDs are not. This rule applies to all media tools, present
|
|
and future.
|
|
|
|
Always confirm successful actions to the user. If a tool fails, tell the user
|
|
what went wrong and suggest alternatives.""",
|
|
tools=TOOLS,
|
|
execute=_execute,
|
|
)
|
|
|
|
register(seerr_skill)
|