The Pirate Phase 1.b: Lidarr + Whisparr + Overseerr tools
Adds 9 read-only tools across three new services: - Lidarr: queue, artist_search, library_stats (music library awareness) - Whisparr: queue, series_search, library_stats (adult content, v3 API) - Overseerr: search, requests, request_counts (cross-library availability, request tracking via X-Api-Key header reusing arr_get helper) Fix _common.py urlencode to use %20 instead of + for query values — Overseerr rejects + as reserved. Safe for all arr services. Pirate catalog: 11 → 20 tools.
This commit is contained in:
@@ -7,13 +7,13 @@ The runtime loads all modules here and builds a combined catalog. The LLM picks
|
|||||||
which tool to call; the runtime executes it and returns the result as a tool message.
|
which tool to call; the runtime executes it and returns the result as a tool message.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import sonarr, radarr, qbittorrent, storage
|
from . import sonarr, radarr, lidarr, whisparr, qbittorrent, storage, overseerr
|
||||||
|
|
||||||
|
|
||||||
def build_catalog():
|
def build_catalog():
|
||||||
"""Return a list of all available tools across all modules."""
|
"""Return a list of all available tools across all modules."""
|
||||||
catalog = []
|
catalog = []
|
||||||
for mod in (sonarr, radarr, qbittorrent, storage):
|
for mod in (sonarr, radarr, lidarr, whisparr, qbittorrent, storage, overseerr):
|
||||||
catalog.extend(getattr(mod, "TOOLS", []))
|
catalog.extend(getattr(mod, "TOOLS", []))
|
||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def arr_get(service_name, path, params=None):
|
|||||||
raise RuntimeError(f"{service_name} not configured (base_url + api_key required)")
|
raise RuntimeError(f"{service_name} not configured (base_url + api_key required)")
|
||||||
url = f"{base}{path}"
|
url = f"{base}{path}"
|
||||||
if params:
|
if params:
|
||||||
url += "?" + urlparse.urlencode(params)
|
url += "?" + urlparse.urlencode(params, quote_via=urlparse.quote)
|
||||||
req = urlreq.Request(url, headers={"X-Api-Key": key})
|
req = urlreq.Request(url, headers={"X-Api-Key": key})
|
||||||
with urlreq.urlopen(req, timeout=15) as resp:
|
with urlreq.urlopen(req, timeout=15) as resp:
|
||||||
return json.loads(resp.read().decode())
|
return json.loads(resp.read().decode())
|
||||||
@@ -41,7 +41,7 @@ def qbit_request(path, method="GET", params=None, data=None, cookies=None):
|
|||||||
raise RuntimeError("qbittorrent not configured")
|
raise RuntimeError("qbittorrent not configured")
|
||||||
url = f"{base}{path}"
|
url = f"{base}{path}"
|
||||||
if params:
|
if params:
|
||||||
url += "?" + urlparse.urlencode(params)
|
url += "?" + urlparse.urlencode(params, quote_via=urlparse.quote)
|
||||||
body = None
|
body = None
|
||||||
headers = {}
|
headers = {}
|
||||||
if cookies:
|
if cookies:
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Lidarr tools — music library queries (read-only)."""
|
||||||
|
|
||||||
|
from ._common import arr_get
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_artist(a):
|
||||||
|
st = a.get("statistics", {}) or {}
|
||||||
|
return {
|
||||||
|
"id": a.get("id"),
|
||||||
|
"name": a.get("artistName"),
|
||||||
|
"status": a.get("status"),
|
||||||
|
"monitored": a.get("monitored"),
|
||||||
|
"genres": a.get("genres", []),
|
||||||
|
"album_count": st.get("albumCount"),
|
||||||
|
"tracks_on_disk": st.get("trackFileCount"),
|
||||||
|
"tracks_total": st.get("totalTrackCount"),
|
||||||
|
"size_on_disk_gb": round((st.get("sizeOnDisk", 0) or 0) / 1e9, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lidarr_queue(limit=20):
|
||||||
|
"""Music albums/tracks currently downloading or pending import."""
|
||||||
|
data = arr_get("lidarr", "/api/v1/queue", {
|
||||||
|
"pageSize": limit,
|
||||||
|
"includeArtist": "true",
|
||||||
|
"includeAlbum": "true",
|
||||||
|
})
|
||||||
|
items = []
|
||||||
|
for r in data.get("records", []):
|
||||||
|
items.append({
|
||||||
|
"title": r.get("title"),
|
||||||
|
"artist": r.get("artist", {}).get("artistName") if r.get("artist") else None,
|
||||||
|
"album": r.get("album", {}).get("title") if r.get("album") else None,
|
||||||
|
"status": r.get("status"),
|
||||||
|
"timeleft": r.get("timeleft"),
|
||||||
|
"size_mb": round((r.get("size", 0) or 0) / 1e6, 1),
|
||||||
|
"size_left_mb": round((r.get("sizeleft", 0) or 0) / 1e6, 1),
|
||||||
|
"download_client": r.get("downloadClient"),
|
||||||
|
})
|
||||||
|
return {"total_records": data.get("totalRecords", 0), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def lidarr_artist_search(query):
|
||||||
|
"""Look up an artist in the user's Lidarr library by partial name match.
|
||||||
|
Does NOT search Prowlarr/indexers — only what's already tracked."""
|
||||||
|
all_artists = arr_get("lidarr", "/api/v1/artist")
|
||||||
|
q = query.lower().strip()
|
||||||
|
hits = [a for a in all_artists if q in (a.get("artistName") or "").lower()]
|
||||||
|
return {"query": query, "count": len(hits), "artists": [_compact_artist(a) for a in hits[:20]]}
|
||||||
|
|
||||||
|
|
||||||
|
def lidarr_library_stats():
|
||||||
|
"""Summary stats of the Lidarr music library."""
|
||||||
|
all_artists = arr_get("lidarr", "/api/v1/artist")
|
||||||
|
total_albums = total_tracks = total_on_disk = total_size = 0
|
||||||
|
for a in all_artists:
|
||||||
|
st = a.get("statistics", {}) or {}
|
||||||
|
total_albums += st.get("albumCount", 0) or 0
|
||||||
|
total_tracks += st.get("totalTrackCount", 0) or 0
|
||||||
|
total_on_disk += st.get("trackFileCount", 0) or 0
|
||||||
|
total_size += st.get("sizeOnDisk", 0) or 0
|
||||||
|
return {
|
||||||
|
"artist_count": len(all_artists),
|
||||||
|
"album_count": total_albums,
|
||||||
|
"tracks_total": total_tracks,
|
||||||
|
"tracks_on_disk": total_on_disk,
|
||||||
|
"total_size_gb": round(total_size / 1e9, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "lidarr_queue",
|
||||||
|
"description": "List music albums/tracks Lidarr is currently downloading or importing. Use when the user asks 'what music is downloading', 'is the new album in yet'.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"limit": {"type": "integer", "description": "Max rows to return (default 20)", "default": 20}},
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": lidarr_queue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lidarr_artist_search",
|
||||||
|
"description": "Check whether an artist is in the user's Lidarr library by partial name match. Does NOT search for new artists to add.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string", "description": "Partial or full artist name"}},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": lidarr_artist_search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lidarr_library_stats",
|
||||||
|
"description": "High-level stats about the Lidarr music library: artist count, album count, tracks on disk, total size.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": lidarr_library_stats,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Overseerr tools — cross-library media search + request tracking (read-only).
|
||||||
|
|
||||||
|
Overseerr is the *request portal* sitting in front of Radarr/Sonarr, so it knows:
|
||||||
|
- Whether a title is already in the library (via Plex sync)
|
||||||
|
- Whether someone has requested it and what state the request is in
|
||||||
|
|
||||||
|
Reuses the `arr_get` helper because Overseerr accepts the same `X-Api-Key` header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ._common import arr_get
|
||||||
|
|
||||||
|
# Overseerr status enums (from their API docs)
|
||||||
|
_REQUEST_STATUS = {1: "pending", 2: "approved", 3: "declined"}
|
||||||
|
_MEDIA_STATUS = {1: "unknown", 2: "pending", 3: "processing", 4: "partially_available", 5: "available"}
|
||||||
|
|
||||||
|
|
||||||
|
def overseerr_search(query, limit=10):
|
||||||
|
"""Search TMDB via Overseerr for movies or TV shows.
|
||||||
|
Returns whether each result is already in the library, in progress, or not yet requested."""
|
||||||
|
data = arr_get("overseerr", "/api/v1/search", {"query": query})
|
||||||
|
out = []
|
||||||
|
for r in (data.get("results") or [])[:limit]:
|
||||||
|
info = r.get("mediaInfo") or {}
|
||||||
|
media_status_code = info.get("status")
|
||||||
|
out.append({
|
||||||
|
"tmdb_id": r.get("id"),
|
||||||
|
"type": r.get("mediaType"),
|
||||||
|
"title": r.get("title") or r.get("name"),
|
||||||
|
"year": (r.get("releaseDate") or r.get("firstAirDate") or "")[:4],
|
||||||
|
"overview": (r.get("overview") or "")[:200],
|
||||||
|
"media_status": _MEDIA_STATUS.get(media_status_code, "not_in_library") if media_status_code else "not_in_library",
|
||||||
|
})
|
||||||
|
return {"query": query, "total_results": data.get("totalResults", 0), "results": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_request_title(media):
|
||||||
|
"""Given a request's media object, fetch its title via /movie/{tmdbId} or /tv/{tmdbId}."""
|
||||||
|
mtype = media.get("mediaType")
|
||||||
|
tid = media.get("tmdbId")
|
||||||
|
if not mtype or not tid:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = arr_get("overseerr", f"/api/v1/{mtype}/{tid}")
|
||||||
|
return data.get("title") or data.get("name")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def overseerr_requests(status="all", limit=20):
|
||||||
|
"""List recent Overseerr media requests, optionally filtered by status.
|
||||||
|
status: all | pending | approved | processing | available | unavailable."""
|
||||||
|
valid = {"all", "pending", "approved", "processing", "available", "unavailable"}
|
||||||
|
if status not in valid:
|
||||||
|
status = "all"
|
||||||
|
params = {"take": limit, "sort": "added"}
|
||||||
|
if status != "all":
|
||||||
|
params["filter"] = status
|
||||||
|
data = arr_get("overseerr", "/api/v1/request", params)
|
||||||
|
out = []
|
||||||
|
for r in (data.get("results") or [])[:limit]:
|
||||||
|
media = r.get("media") or {}
|
||||||
|
out.append({
|
||||||
|
"id": r.get("id"),
|
||||||
|
"type": r.get("type"),
|
||||||
|
"request_status": _REQUEST_STATUS.get(r.get("status"), "unknown"),
|
||||||
|
"created_at": r.get("createdAt"),
|
||||||
|
"title": _resolve_request_title(media),
|
||||||
|
"media_status": _MEDIA_STATUS.get(media.get("status"), "unknown"),
|
||||||
|
"requested_by": (r.get("requestedBy") or {}).get("displayName"),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"status_filter": status,
|
||||||
|
"count": len(out),
|
||||||
|
"page_info": data.get("pageInfo"),
|
||||||
|
"requests": out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def overseerr_request_counts():
|
||||||
|
"""High-level counts of Overseerr requests by status and type."""
|
||||||
|
return arr_get("overseerr", "/api/v1/request/count")
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "overseerr_search",
|
||||||
|
"description": "Search TMDB via Overseerr for movies or TV shows. Returns whether each result is already in the library (available), in progress (processing/pending), or not yet requested. Use when the user asks 'do we have X?', 'is Y available?', or 'has anyone requested Z?' — this is the best cross-library check because Overseerr knows Radarr, Sonarr, and Plex state.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"limit": {"type": "integer", "description": "Max results to return (default 10)", "default": 10},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": overseerr_search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overseerr_requests",
|
||||||
|
"description": "List recent Overseerr media requests, optionally filtered by status. Use when the user asks 'what's pending', 'what did Angela request', 'what's processing'. Status values: all, pending, approved, processing, available, unavailable.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {"type": "string", "description": "Filter: all, pending, approved, processing, available, unavailable", "default": "all"},
|
||||||
|
"limit": {"type": "integer", "default": 20},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": overseerr_requests,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "overseerr_request_counts",
|
||||||
|
"description": "High-level counts of Overseerr requests broken down by status (pending/approved/declined/processing/available) and type (movie/tv).",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": overseerr_request_counts,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Whisparr tools — adult library queries (read-only).
|
||||||
|
|
||||||
|
Whisparr V2 uses a Sonarr-style series/episode model with `/api/v3/` paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ._common import arr_get
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_series(s):
|
||||||
|
st = s.get("statistics", {}) or {}
|
||||||
|
return {
|
||||||
|
"id": s.get("id"),
|
||||||
|
"title": s.get("title"),
|
||||||
|
"year": s.get("year"),
|
||||||
|
"status": s.get("status"),
|
||||||
|
"monitored": s.get("monitored"),
|
||||||
|
"episode_count": st.get("episodeCount"),
|
||||||
|
"episode_file_count": st.get("episodeFileCount"),
|
||||||
|
"size_on_disk_gb": round((st.get("sizeOnDisk", 0) or 0) / 1e9, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def whisparr_queue(limit=20):
|
||||||
|
"""Adult content currently downloading or pending import."""
|
||||||
|
data = arr_get("whisparr", "/api/v3/queue", {"pageSize": limit, "includeSeries": "true"})
|
||||||
|
items = []
|
||||||
|
for r in data.get("records", []):
|
||||||
|
items.append({
|
||||||
|
"title": r.get("title"),
|
||||||
|
"series": r.get("series", {}).get("title") if r.get("series") else None,
|
||||||
|
"status": r.get("status"),
|
||||||
|
"timeleft": r.get("timeleft"),
|
||||||
|
"size_gb": round((r.get("size", 0) or 0) / 1e9, 2),
|
||||||
|
"size_left_gb": round((r.get("sizeleft", 0) or 0) / 1e9, 2),
|
||||||
|
"download_client": r.get("downloadClient"),
|
||||||
|
})
|
||||||
|
return {"total_records": data.get("totalRecords", 0), "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
def whisparr_series_search(query):
|
||||||
|
"""Look up a title in the user's Whisparr library by partial match.
|
||||||
|
Does NOT search indexers — only what's already tracked."""
|
||||||
|
all_series = arr_get("whisparr", "/api/v3/series")
|
||||||
|
q = query.lower().strip()
|
||||||
|
hits = [s for s in all_series if q in (s.get("title") or "").lower()]
|
||||||
|
return {"query": query, "count": len(hits), "series": [_compact_series(s) for s in hits[:20]]}
|
||||||
|
|
||||||
|
|
||||||
|
def whisparr_library_stats():
|
||||||
|
"""Summary stats of the Whisparr adult library."""
|
||||||
|
all_series = arr_get("whisparr", "/api/v3/series")
|
||||||
|
total_eps = total_on_disk = total_size = 0
|
||||||
|
for s in all_series:
|
||||||
|
st = s.get("statistics", {}) or {}
|
||||||
|
total_eps += st.get("episodeCount", 0) or 0
|
||||||
|
total_on_disk += st.get("episodeFileCount", 0) or 0
|
||||||
|
total_size += st.get("sizeOnDisk", 0) or 0
|
||||||
|
return {
|
||||||
|
"series_count": len(all_series),
|
||||||
|
"episode_total": total_eps,
|
||||||
|
"episodes_on_disk": total_on_disk,
|
||||||
|
"total_size_gb": round(total_size / 1e9, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "whisparr_queue",
|
||||||
|
"description": "List adult content Whisparr is currently downloading or importing.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"limit": {"type": "integer", "default": 20}},
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": whisparr_queue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whisparr_series_search",
|
||||||
|
"description": "Search the user's Whisparr library by partial title match. Does NOT search indexers.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": whisparr_series_search,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "whisparr_library_stats",
|
||||||
|
"description": "High-level stats about the Whisparr adult library: title count, episodes on disk, total size.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
"read_only": True,
|
||||||
|
"fn": whisparr_library_stats,
|
||||||
|
},
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user