Files
Agents/skills/seerr.py
T

927 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 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
# ---------------------------------------------------------------------------
_seerr_session: httpx.AsyncClient | None = None
def _init_session() -> None:
"""Initialise the Seerr session once at module load.
Uses httpx.Client (sync!) for the one-time login, then creates an
async client with the resulting cookies. No async event-loop tricks.
"""
global _seerr_session
if _seerr_session is not None:
return
cookies: dict = {}
if SEERR_USERNAME.strip() and SEERR_PASSWORD.strip():
# --- Cookie-based auth: login via sync client ---
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()
cookies = dict(sync_client.cookies)
except httpx.HTTPError:
pass # fall through to API key
finally:
sync_client.close()
# Build the async session
if cookies:
_seerr_session = httpx.AsyncClient(
base_url=SEERR_URL,
cookies=cookies, # ← cookie auth
timeout=SEERR_TIMEOUT,
)
elif SEERR_API_KEY.strip():
_seerr_session = httpx.AsyncClient(
base_url=SEERR_URL,
headers={"X-Api-Key": SEERR_API_KEY.strip()},
timeout=SEERR_TIMEOUT,
)
else:
_seerr_session = httpx.AsyncClient(
base_url=SEERR_URL,
timeout=SEERR_TIMEOUT,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _SharedClient:
"""Wraps the shared httpx.AsyncClient so that `async with` doesn't
close it when the context exits. All 11 call sites use:
async with _client() as c:
Without this wrapper, httpx would close the shared session after
the first call, breaking every subsequent tool execution.
"""
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 shared httpx session."""
assert _seerr_session is not None, "Seerr session not initialised"
return _SharedClient(_seerr_session)
# Initialise at import time
_init_session()
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)