eac7b64a90
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).
88 lines
3.2 KiB
Python
88 lines
3.2 KiB
Python
"""Radarr tools — movie queries (read-only)."""
|
|
|
|
from ._common import arr_get
|
|
|
|
|
|
def _compact_movie(m):
|
|
return {
|
|
"id": m.get("id"),
|
|
"title": m.get("title"),
|
|
"year": m.get("year"),
|
|
"monitored": m.get("monitored"),
|
|
"has_file": m.get("hasFile"),
|
|
"status": m.get("status"),
|
|
"size_on_disk_gb": round((m.get("sizeOnDisk", 0) or 0) / 1e9, 1),
|
|
}
|
|
|
|
|
|
def radarr_queue(limit=20):
|
|
"""Movies currently downloading or pending import."""
|
|
data = arr_get("radarr", "/api/v3/queue", {"pageSize": limit, "includeMovie": "true"})
|
|
items = []
|
|
for r in data.get("records", []):
|
|
items.append({
|
|
"title": r.get("title"),
|
|
"movie": r.get("movie", {}).get("title") if r.get("movie") 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 radarr_movie_search(query):
|
|
"""Search the user's Radarr library for a movie by title. Does NOT search indexers."""
|
|
all_movies = arr_get("radarr", "/api/v3/movie")
|
|
q = query.lower().strip()
|
|
hits = [m for m in all_movies if q in (m.get("title") or "").lower()]
|
|
return {"query": query, "count": len(hits), "movies": [_compact_movie(m) for m in hits[:20]]}
|
|
|
|
|
|
def radarr_library_stats():
|
|
"""Summary stats of the Radarr movie library."""
|
|
all_movies = arr_get("radarr", "/api/v3/movie")
|
|
on_disk = sum(1 for m in all_movies if m.get("hasFile"))
|
|
total_size = sum((m.get("sizeOnDisk", 0) or 0) for m in all_movies)
|
|
missing = [m.get("title") for m in all_movies if not m.get("hasFile") and m.get("monitored")][:10]
|
|
return {
|
|
"movie_count": len(all_movies),
|
|
"movies_on_disk": on_disk,
|
|
"movies_missing_monitored": len([m for m in all_movies if not m.get("hasFile") and m.get("monitored")]),
|
|
"total_size_tb": round(total_size / 1e12, 2),
|
|
"sample_missing": missing,
|
|
}
|
|
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "radarr_queue",
|
|
"description": "List movies Radarr is currently downloading or importing. Use when the user asks about movie downloads.",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"limit": {"type": "integer", "default": 20}},
|
|
},
|
|
"read_only": True,
|
|
"fn": radarr_queue,
|
|
},
|
|
{
|
|
"name": "radarr_movie_search",
|
|
"description": "Check whether a specific movie is in the user's Radarr library. Does NOT search for new movies to add.",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
"read_only": True,
|
|
"fn": radarr_movie_search,
|
|
},
|
|
{
|
|
"name": "radarr_library_stats",
|
|
"description": "High-level stats about the Radarr movie library: how many movies, how many on disk, total size.",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"read_only": True,
|
|
"fn": radarr_library_stats,
|
|
},
|
|
]
|