Files
Eric Jungbauer eac7b64a90 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).
2026-04-20 19:01:50 +00:00

128 lines
5.1 KiB
Python

"""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,
},
]