diff --git a/agents/pirate/tools/__init__.py b/agents/pirate/tools/__init__.py index 15f94d2..6850d11 100644 --- a/agents/pirate/tools/__init__.py +++ b/agents/pirate/tools/__init__.py @@ -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. """ -from . import sonarr, radarr, qbittorrent, storage +from . import sonarr, radarr, lidarr, whisparr, qbittorrent, storage, overseerr def build_catalog(): """Return a list of all available tools across all modules.""" catalog = [] - for mod in (sonarr, radarr, qbittorrent, storage): + for mod in (sonarr, radarr, lidarr, whisparr, qbittorrent, storage, overseerr): catalog.extend(getattr(mod, "TOOLS", [])) return catalog diff --git a/agents/pirate/tools/_common.py b/agents/pirate/tools/_common.py index 1268b83..ef2db92 100644 --- a/agents/pirate/tools/_common.py +++ b/agents/pirate/tools/_common.py @@ -27,7 +27,7 @@ def arr_get(service_name, path, params=None): raise RuntimeError(f"{service_name} not configured (base_url + api_key required)") url = f"{base}{path}" if params: - url += "?" + urlparse.urlencode(params) + url += "?" + urlparse.urlencode(params, quote_via=urlparse.quote) req = urlreq.Request(url, headers={"X-Api-Key": key}) with urlreq.urlopen(req, timeout=15) as resp: 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") url = f"{base}{path}" if params: - url += "?" + urlparse.urlencode(params) + url += "?" + urlparse.urlencode(params, quote_via=urlparse.quote) body = None headers = {} if cookies: diff --git a/agents/pirate/tools/lidarr.py b/agents/pirate/tools/lidarr.py new file mode 100644 index 0000000..41d79f8 --- /dev/null +++ b/agents/pirate/tools/lidarr.py @@ -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, + }, +] diff --git a/agents/pirate/tools/overseerr.py b/agents/pirate/tools/overseerr.py new file mode 100644 index 0000000..981db53 --- /dev/null +++ b/agents/pirate/tools/overseerr.py @@ -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, + }, +] diff --git a/agents/pirate/tools/whisparr.py b/agents/pirate/tools/whisparr.py new file mode 100644 index 0000000..4814e4a --- /dev/null +++ b/agents/pirate/tools/whisparr.py @@ -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, + }, +]