The Pirate — Phase 1.a: conversational read-only media agent
Ships a chat-based agent at /pirate that LLM-routes user questions to media-stack tools and returns natural-language answers grounded in real data. Foundation built on top of the existing API-tokens + dual-auth infrastructure so other apps (Open WebUI, HA voice, Synap) can consume the same Pirate API. New subsystem (not the standard trigger/result pattern): - pirate_conversations + pirate_messages tables - service_configs table (admin-wide creds shared by media agents) - /api/pirate/chat + /api/pirate/conversations/* (dual-auth: user session OR Bearer token scoped to user's pirate instance) - /api/internal/pirate/* endpoints used by runtime subprocess - /api/admin/services + Services tab in admin.html for cred management - Auto-seeded service_configs on startup from Media Stack Reference defaults (never overwrite admin edits) - Auto-seeded pirate catalog entry + per-user pirate instance on startup Pirate package (agents/pirate/): - prompts.py: system prompt, enforces read-only in Phase 1 - runtime.py: Anthropic-native tool-use loop (max 8 iterations, persists every turn) - tools/_common.py: service_configs fetch + qBit session auth - tools/sonarr.py: queue, upcoming, series_search, library_stats - tools/radarr.py: queue, movie_search, library_stats - tools/qbittorrent.py: torrents, transfer_stats, categories - tools/storage.py: disk_space (via Sonarr diskspace API) - Default model: claude-sonnet-4-5 (Haiku fumbles multi-step chains) Dashboard: - static/pirate.html — full chat UI with conversation sidebar, suggestion chips, inline tool-call visualization, 24h idle reset + New Chat button - Pirate button added to main dashboard header Wiki reorg: Agents / Developer Guides / Plans parent docs, per-agent reference docs, The Pirate doc. API Clients + Calling Agents docs moved under Developer Guides. Working folder: PIRATE_PHASE_1A.md + NEXT_SESSION_PROMPT.md for fast bootstrap. Smoke tested end-to-end: real tool calls against qBittorrent (13 active torrents correctly reported) and Sonarr disk-space; multi-turn conversation state preserved across follow-up questions. On deck: Phase 1.b (Lidarr/Whisparr/Overseerr/Plex tools), then 1.d (OWUI pipeline), then 1.c (HA voice).
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Sonarr tools — TV show queries (read-only)."""
|
||||
|
||||
from ._common import arr_get
|
||||
|
||||
|
||||
def _compact_series(s):
|
||||
return {
|
||||
"id": s.get("id"),
|
||||
"title": s.get("title"),
|
||||
"year": s.get("year"),
|
||||
"status": s.get("status"),
|
||||
"monitored": s.get("monitored"),
|
||||
"network": s.get("network"),
|
||||
"episode_count": s.get("statistics", {}).get("episodeCount"),
|
||||
"episode_file_count": s.get("statistics", {}).get("episodeFileCount"),
|
||||
"size_on_disk_gb": round((s.get("statistics", {}).get("sizeOnDisk", 0) or 0) / 1e9, 1),
|
||||
}
|
||||
|
||||
|
||||
def sonarr_queue(limit=20):
|
||||
"""What TV episodes are currently downloading / pending import."""
|
||||
data = arr_get("sonarr", "/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),
|
||||
"protocol": r.get("protocol"),
|
||||
"download_client": r.get("downloadClient"),
|
||||
})
|
||||
return {"total_records": data.get("totalRecords", 0), "items": items}
|
||||
|
||||
|
||||
def sonarr_upcoming(days=14):
|
||||
"""TV episodes expected to air (or just released) in the next N days."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
end = now + timedelta(days=days)
|
||||
data = arr_get("sonarr", "/api/v3/calendar", {
|
||||
"start": now.date().isoformat(),
|
||||
"end": end.date().isoformat(),
|
||||
"includeSeries": "true",
|
||||
})
|
||||
out = []
|
||||
for ep in data:
|
||||
out.append({
|
||||
"series": ep.get("series", {}).get("title"),
|
||||
"season": ep.get("seasonNumber"),
|
||||
"episode": ep.get("episodeNumber"),
|
||||
"title": ep.get("title"),
|
||||
"air_date": ep.get("airDateUtc") or ep.get("airDate"),
|
||||
"has_file": ep.get("hasFile"),
|
||||
"monitored": ep.get("monitored"),
|
||||
})
|
||||
return {"window_days": days, "episodes": out}
|
||||
|
||||
|
||||
def sonarr_series_search(query):
|
||||
"""Look up a series in the user's Sonarr library by partial title match.
|
||||
Note: this does NOT search Prowlarr/indexers — it only searches what's already tracked."""
|
||||
all_series = arr_get("sonarr", "/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 sonarr_library_stats():
|
||||
"""Summary of the Sonarr library: how many series, how many episodes on disk, total size."""
|
||||
all_series = arr_get("sonarr", "/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_tb": round(total_size / 1e12, 2),
|
||||
}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "sonarr_queue",
|
||||
"description": "List TV episodes that Sonarr is currently downloading or has just imported. Use when the user asks 'what's downloading', 'what episodes are coming in', 'is the new episode in yet'.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer", "description": "Max rows to return (default 20)", "default": 20}},
|
||||
},
|
||||
"read_only": True,
|
||||
"fn": sonarr_queue,
|
||||
},
|
||||
{
|
||||
"name": "sonarr_upcoming",
|
||||
"description": "List TV episodes expected to air in the next N days (default 14). Use when the user asks 'what's coming up this week', 'when does the next episode drop', 'what's airing soon'.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"days": {"type": "integer", "description": "Look ahead window in days (default 14)", "default": 14}},
|
||||
},
|
||||
"read_only": True,
|
||||
"fn": sonarr_upcoming,
|
||||
},
|
||||
{
|
||||
"name": "sonarr_series_search",
|
||||
"description": "Search the user's Sonarr library for a TV series by title. Does NOT search for new series to add — only checks what's already tracked.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Partial or full series title"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
"read_only": True,
|
||||
"fn": sonarr_series_search,
|
||||
},
|
||||
{
|
||||
"name": "sonarr_library_stats",
|
||||
"description": "High-level stats about the Sonarr TV library: number of series, episodes on disk, total size. Use when the user asks 'how big is the TV library' or 'how many shows do we have'.",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"read_only": True,
|
||||
"fn": sonarr_library_stats,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user