fixed api calls with seerr, added full context for models, beginning to standardizing single id as source of truths for future tools
Build and Push Agent API / build (push) Successful in 14s

This commit is contained in:
2026-05-14 14:25:48 +02:00
parent d943d4bd31
commit 2adf17493a
5 changed files with 692 additions and 161 deletions
+396 -48
View File
@@ -37,7 +37,8 @@ def _client() -> httpx.AsyncClient:
def _fmt_items(items: list[dict], kind: str) -> str:
"""Format a list of media items for the LLM to present."""
"""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"
@@ -46,8 +47,10 @@ def _fmt_items(items: list[dict], kind: str) -> str:
or item.get("firstAirDate", "")[:4]
or "?"
)
tmdb_id = item.get("id", "")
overview = (item.get("overview") or "")[:120]
lines.append(f"{i}. **{title}** ({year}) — {overview}")
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)
@@ -160,6 +163,104 @@ TOOLS = [
},
},
},
{
"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": {
@@ -218,6 +319,9 @@ async def _execute(tool_name: str, args: dict) -> ToolResult:
"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:
@@ -277,9 +381,11 @@ async def _trending(args: dict) -> ToolResult:
async def _discover(args: dict) -> ToolResult:
"""Jellyseerr discover endpoints:
Genre: /api/v1/discover/{movies|tv}/genre/{genreId}
Studio: /api/v1/discover/{movies|tv}/studio/{studioId}
Keyword: /api/v1/discover/{movies|tv}?query=keyword
Language falls back to the base discover endpoint.
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()
@@ -298,9 +404,8 @@ async def _discover(args: dict) -> ToolResult:
"war": 10752, "western": 37,
}
base = f"/api/v1/discover/{'movies' if kind == 'movie' else 'tv'}"
params: dict = {"page": page}
endpoint = base
endpoint: str
if genre:
genre_id = genre_map.get(genre.lower())
@@ -309,22 +414,52 @@ async def _discover(args: dict) -> ToolResult:
f"I don't recognise the genre '{genre}'. "
f"Try one of: {', '.join(sorted(genre_map.keys()))}."
)
endpoint = f"{base}/genre/{genre_id}"
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:
endpoint = f"{base}/studio/{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
endpoint = base
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 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 or studio or keyword or kind
if not results:
return ToolResult.ok(f"No {desc} {kind}s found.")
return ToolResult.ok(_fmt_items(results, f"{desc} {kind}s"))
@@ -335,16 +470,52 @@ async def _request_media(args: dict) -> ToolResult:
title = args["title"]
tmdb_id = args.get("tmdb_id")
# Step 1: Search for the media
async with _client() as c:
r = await c.get("/api/v1/search/", params={"query": quote(title), "page": 1})
# --- 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 if we have results from unified search
# 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:
filtered = results # fallback if mediaType not set
if not filtered:
return ToolResult.fail(
@@ -352,25 +523,35 @@ async def _request_media(args: dict) -> ToolResult:
f"Please double-check the title or provide a TMDb ID."
)
# If tmdb_id provided, match it; otherwise use the first result
match = None
if tmdb_id:
match = next(
(item for item in filtered if item.get("id") == tmdb_id),
None,
)
if not match:
match = filtered[0]
# --- 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))
# Seerr's request endpoint expects the local mediaInfo.id
media_info = match.get("mediaInfo", {})
media_id = media_info.get("id") or match.get("id")
# --- 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]
)
# Step 2: Submit the request
request_body = {
"mediaType": kind,
"mediaId": media_id,
@@ -398,6 +579,154 @@ async def _request_media(args: dict) -> ToolResult:
)
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"]
@@ -407,23 +736,28 @@ async def _submit_issue(args: dict) -> ToolResult:
body: dict = {
"issueType": int(issue_type),
"subject": subject,
"message": description,
}
if media_title:
body["message"] = f"[Media: {media_title}]\n\n{description}"
async with _client() as c:
# --- Resolve mediaId (Seerr's internal ID, not TMDb) ---
# --- 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})
if search_r.status_code == 200:
results = search_r.json().get("results", [])
if results:
# Seerr's /api/v1/issue expects the local mediaInfo.id,
# not the TMDb id at the top level.
media_info = results[0].get("mediaInfo", {})
media_id = media_info.get("id") or results[0].get("id")
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", [])
# 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")
if media_id:
body["mediaId"] = int(media_id)
@@ -449,18 +783,32 @@ async def _submit_issue(args: dict) -> ToolResult:
# ---------------------------------------------------------------------------
seerr_skill = Skill(
name="seerr",
description="Seerr integration — trending, discover, request media, submit issues",
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,