""" 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)