Issue #2. Search/request now work without Seerr — when seerr.url/api_key are left as placeholders the bot falls back to direct Sonarr/Radarr lookup + add (mirrors how Lidarr music already works). Numbered selection keeps working across both sources via a _source discriminator stamped onto each result. !media requests / !media trending now print a friendly hint when Seerr is absent. base-config.yaml no longer ships any homelab-specific URLs, MXIDs, or Emby UIDs — admin_users defaults to [], user_map to {}, and every service URL uses a docker-hostname placeholder. New per-service config keys (quality_profile_id, root_folder_path, monitor, search_on_add, language_profile_id, minimum_availability) let operators pin Sonarr/ Radarr defaults the same way Lidarr already could; null = auto-pick the first profile/folder. README rewritten as a self-contained setup guide: requirements, build, upload, instance config (Required vs Optional with the Seerr fallback called out), webhook setup, fork notes.
137 lines
5.4 KiB
Python
137 lines
5.4 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 [])
|
|
|
|
async def lookup(self, term: str) -> list[dict]:
|
|
data = await self._get("/api/v3/series/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/v3/qualityprofile")
|
|
return data if isinstance(data, list) else []
|
|
|
|
async def language_profiles(self) -> list[dict]:
|
|
# Sonarr v4 dropped these; v3 still has them. Empty list = skip the field.
|
|
try:
|
|
data = await self._get("/api/v3/languageprofile")
|
|
return data if isinstance(data, list) else []
|
|
except ArrError:
|
|
return []
|
|
|
|
async def root_folders(self) -> list[dict]:
|
|
data = await self._get("/api/v3/rootfolder")
|
|
return data if isinstance(data, list) else []
|
|
|
|
async def add_series(self, series: dict) -> dict:
|
|
return await self._post("/api/v3/series", series) # type: ignore[return-value]
|
|
|
|
|
|
class RadarrClient(ArrClient):
|
|
async def lookup(self, term: str) -> list[dict]:
|
|
data = await self._get("/api/v3/movie/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/v3/qualityprofile")
|
|
return data if isinstance(data, list) else []
|
|
|
|
async def root_folders(self) -> list[dict]:
|
|
data = await self._get("/api/v3/rootfolder")
|
|
return data if isinstance(data, list) else []
|
|
|
|
async def add_movie(self, movie: dict) -> dict:
|
|
return await self._post("/api/v3/movie", movie) # type: ignore[return-value]
|
|
|
|
|
|
class LidarrClient(ArrClient):
|
|
"""Lidarr API v1 — note the version difference from Sonarr/Radarr."""
|
|
|
|
async def health(self) -> list[dict]:
|
|
data = await self._get("/api/v1/health")
|
|
return data if isinstance(data, list) else (data or [])
|
|
|
|
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]
|