maubot-media/media_bot/clients/arr.py
Maddox e042b325b5 v0.5.0: Lidarr music search + add
- LidarrClient (api/v1) with lookup, profile/folder discovery, and
  add_artist
- !media music <query> — MusicBrainz lookup via Lidarr, numbered list
  cached per (room, sender)
- !media music add <query|N> — POST /api/v1/artist with profile/folder
  defaults (auto-picks first if not set in config). Carries through
  images/genres/links from the lookup result and kicks off a search
  for missing albums by default.
- New config block 'lidarr' with optional quality_profile_id /
  metadata_profile_id / root_folder_path overrides.
2026-04-29 07:03:13 -04:00

97 lines
3.7 KiB
Python

"""Sonarr / Radarr v3 HTTP clients (shared base)."""
from __future__ import annotations
import aiohttp
class ArrError(RuntimeError):
pass
class ArrClient:
def __init__(self, session: aiohttp.ClientSession, base_url: str, api_key: str) -> None:
self.session = session
self.base = base_url.rstrip("/")
self.headers = {"X-Api-Key": api_key, "Accept": "application/json"}
async def _get(self, path: str, params: dict | None = None) -> dict | list:
async with self.session.get(f"{self.base}{path}", headers=self.headers, params=params) as r:
if r.status >= 400:
raise ArrError(f"GET {path}{r.status}: {(await r.text())[:200]}")
return await r.json()
async def _post(self, path: str, body: dict) -> dict | list:
async with self.session.post(f"{self.base}{path}", headers=self.headers, json=body) as r:
if r.status >= 400:
raise ArrError(f"POST {path}{r.status}: {(await r.text())[:200]}")
return await r.json()
async def health(self) -> list[dict]:
"""Returns active health-check warnings (empty list = healthy)."""
data = await self._get("/api/v3/health")
return data if isinstance(data, list) else (data or [])
async def queue(self, page_size: int = 50) -> list[dict]:
params = {
"pageSize": page_size,
"includeUnknownSeriesItems": "false",
"includeUnknownMovieItems": "false",
"includeSeries": "true",
"includeMovie": "true",
"includeEpisode": "true",
}
data = await self._get("/api/v3/queue", params=params)
return (data or {}).get("records", []) if isinstance(data, dict) else (data or [])
class SonarrClient(ArrClient):
async def list_series(self) -> list[dict]:
"""Full list of series Sonarr knows about (used for subscription lookup)."""
data = await self._get("/api/v3/series")
return data if isinstance(data, list) else (data or [])
async def calendar(self, start: str, end: str) -> list[dict]:
data = await self._get(
"/api/v3/calendar",
params={"start": start, "end": end, "includeSeries": "true"},
)
return data if isinstance(data, list) else (data or [])
async def missing(self, page_size: int = 10) -> list[dict]:
params = {
"pageSize": page_size,
"sortKey": "airDateUtc",
"sortDirection": "descending",
"monitored": "true",
"includeSeries": "true",
}
data = await self._get("/api/v3/wanted/missing", params=params)
return (data or {}).get("records", []) if isinstance(data, dict) else (data or [])
class RadarrClient(ArrClient):
pass
class LidarrClient(ArrClient):
"""Lidarr API v1 — note the version difference from Sonarr/Radarr."""
async def lookup(self, term: str) -> list[dict]:
data = await self._get("/api/v1/artist/lookup", params={"term": term})
return data if isinstance(data, list) else (data or [])
async def quality_profiles(self) -> list[dict]:
data = await self._get("/api/v1/qualityprofile")
return data if isinstance(data, list) else []
async def metadata_profiles(self) -> list[dict]:
data = await self._get("/api/v1/metadataprofile")
return data if isinstance(data, list) else []
async def root_folders(self) -> list[dict]:
data = await self._get("/api/v1/rootfolder")
return data if isinstance(data, list) else []
async def add_artist(self, payload: dict) -> dict:
return await self._post("/api/v1/artist", payload) # type: ignore[return-value]