From ada4d7261af3c8a85a36bd51695ce3a97ed2577c Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Thu, 21 May 2026 23:36:43 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20arr-cli=20skill=20=E2=80=94=20Rad?= =?UTF-8?q?arr=20+=20Sonarr=20media=20library=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CLIs, one skill wrapper. radarr-cli manages movies (list, lookup, calendar, collections), sonarr-cli manages TV series (list, lookup, episodes, calendar, wanted). Separate API keys and server URLs per app. All cli-builder patterns: --json, --dry-run, --quiet, --verbose, lazy auth, emit() dual-output, structured logging, pre-parsed flags. Signed-off-by: Jasper --- arr-cli/SKILL.md | 156 +++++++++++++++++++++++++++++ arr-cli/scripts/radarr-cli | 163 ++++++++++++++++++++++++++++++ arr-cli/scripts/sonarr-cli | 199 +++++++++++++++++++++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 arr-cli/SKILL.md create mode 100755 arr-cli/scripts/radarr-cli create mode 100755 arr-cli/scripts/sonarr-cli diff --git a/arr-cli/SKILL.md b/arr-cli/SKILL.md new file mode 100644 index 0000000..9dcf44e --- /dev/null +++ b/arr-cli/SKILL.md @@ -0,0 +1,156 @@ +--- +name: arr-cli +description: >- + Manage your media library with Radarr (movies) and Sonarr (TV series) + from the terminal. Search and list movies and series, check calendars, + view wanted/missing episodes, and monitor library status. Use when the + user mentions Radarr, Sonarr, the *arr stack, movie automation, TV + series management, or media server setup. +license: MIT +compatibility: Requires ARR_SERVER_RADARR and ARR_KEY_RADARR (for Radarr) + or ARR_SERVER_SONARR and ARR_KEY_SONARR (for Sonarr), Python 3.8+, and + the `requests` library. API keys from each app's Settings → General. +metadata: + tags: [radarr, sonarr, arr-stack, media-server, movie-automation, tv-series, api-client] + sources: + - https://radarr.video/ + - https://sonarr.tv/ +--- + +# arr-cli — Radarr + Sonarr Media Library Management + +Two CLIs, one skill wrapper. `radarr-cli` for movies, `sonarr-cli` for TV series. Radarr and Sonarr share the same API pattern but use separate server URLs and API keys. + +## Setup + +1. Get API keys from each app: Settings → General → API Key +2. Set environment variables: + +```bash +# Radarr +export ARR_SERVER_RADARR="http://localhost:7878" +export ARR_KEY_RADARR="your-radarr-api-key" + +# Sonarr +export ARR_SERVER_SONARR="http://localhost:8989" +export ARR_KEY_SONARR="your-sonarr-api-key" +``` + +`--help` and `--dry-run` work without credentials on both CLIs. + +## Radarr Commands + +### status — Server info + +```bash +radarr-cli status # version, OS, database +radarr-cli status --json # machine-readable +``` + +### movies — List your movie library + +```bash +radarr-cli movies # all movies +radarr-cli movies --status released # filter by status (released, inCinemas, announced) +radarr-cli movies --limit 10 # top 10 +radarr-cli movies --json # machine-readable +``` + +Icons: ✅ = has file, 👁️ = monitored but missing, 🚫 = unmonitored. + +### lookup — Search for movies to add + +```bash +radarr-cli lookup --term "Dune" # search by title +radarr-cli lookup --term "Dune" --json # includes TMDb ID and overview +``` + +### calendar — Upcoming releases + +```bash +radarr-cli calendar # upcoming releases +radarr-cli calendar --json # machine-readable +``` + +### collections — Movie collections + +```bash +radarr-cli collections # list all collections +radarr-cli collections --json # with movie counts +``` + +## Sonarr Commands + +### status — Server info + +```bash +sonarr-cli status # version, OS, database +sonarr-cli status --json # machine-readable +``` + +### series — List your TV series + +```bash +sonarr-cli series # all series +sonarr-cli series --status continuing # filter (continuing, ended, upcoming) +sonarr-cli series --limit 10 # top 10 +sonarr-cli series --json # machine-readable +``` + +### lookup — Search for series to add + +```bash +sonarr-cli lookup --term "Severance" # search by title +sonarr-cli lookup --term "Severance" --json # with TVDB ID +``` + +### episodes — List episodes of a series + +```bash +sonarr-cli episodes --series-id 1 # all episodes +sonarr-cli episodes --series-id 1 --limit 10 # recent episodes +``` + +Get the series ID from `sonarr-cli series --json`. + +### calendar — Upcoming episodes + +```bash +sonarr-cli calendar # upcoming episodes +sonarr-cli calendar --json # machine-readable +``` + +### wanted — Missing episodes + +```bash +sonarr-cli wanted # missing episodes +sonarr-cli wanted --limit 50 # more results +sonarr-cli wanted --json # machine-readable +``` + +## Global Flags + +All flags work in any position on both CLIs: + +```bash +radarr-cli --json movies # flag before subcommand +radarr-cli movies --json # flag after subcommand +radarr-cli --dry-run lookup --term "Dune" # preview +sonarr-cli --quiet series # suppress non-essential output +``` + +## Known Gotchas + +- **Separate credentials** — Radarr and Sonarr use different API keys and different ports (7878 vs 8989). Set both ARR_KEY_RADARR and ARR_KEY_SONARR. +- **API keys from Settings → General** — Not from the user profile page. Look under the General settings tab in each app. +- **Movie/series IDs are the *arr internal IDs**, not TMDb/TVDB IDs. Use `lookup` first to find the internal ID, or use `--json` to get both. +- **Quality profile IDs** default to 4 (HD-1080p). Override by setting the profile ID in your scripts if you use a different profile. +- **Calendar can be slow** on large libraries. Sonarr's calendar may take several seconds to respond. +- **Sonarr wanted endpoint is paginated** — it returns a `totalRecords` field. Use `--limit` to control page size. + +## References + +- [scripts/radarr-cli](scripts/radarr-cli) — Radarr CLI binary. cli-builder patterns: `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth. +- [scripts/sonarr-cli](scripts/sonarr-cli) — Sonarr CLI binary. Same patterns as radarr-cli. +- [Radarr API docs](https://radarr.video/docs/api/) — Official API reference. +- [Sonarr API docs](https://sonarr.tv/docs/api/) — Official API reference. diff --git a/arr-cli/scripts/radarr-cli b/arr-cli/scripts/radarr-cli new file mode 100755 index 0000000..c8e1dd3 --- /dev/null +++ b/arr-cli/scripts/radarr-cli @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""radarr-cli — Radarr movie management from the terminal.""" + +import argparse, json, os, sys, warnings +from typing import Any, Dict, List, Optional, Tuple +warnings.simplefilter("ignore") +import requests + +ENV_SERVER = os.getenv("ARR_SERVER_RADARR", "http://localhost:7878") +ENV_KEY = os.getenv("ARR_KEY_RADARR", "") + +QUIET = False +GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} +def log(m): global QUIET; G = GLOBAL_FLAGS; (not QUIET and not G.get("json")) and print(m) +def warn(m): print(f"Warning: {m}", file=sys.stderr) +def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c) +def emit(h, d): + if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str)) + else: print(h) + +def _preparse(argv): + BOOLS = {"--json","--dry-run","--force","--quiet","--verbose"} + f, fl = {}, [argv[0]] + i = 1 + while i < len(argv): + a = argv[i] + if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1 + elif a in ("--help","-h"): return f, argv + elif a == "--": fl.extend(argv[i:]); break + else: fl.append(a); i += 1 + return f, fl + +class RadarrClient: + def __init__(self, server="", key="", dry_run=False): + self.server = (server or ENV_SERVER).rstrip("/"); self.key = key or ENV_KEY; self.dry_run = dry_run + def _get(self, path, params=None): + url = f"{self.server}/api/v3{path}" + if self.dry_run: return {"dry_run":True, "url":url, "params":params} + if not self.key: die("ARR_KEY_RADARR not set. Get one from Radarr → Settings → General → API Key.") + try: + r = requests.get(url, params=params, headers={"X-Api-Key": self.key, "Accept":"application/json"}, timeout=30) + except ConnectionError as e: die(f"Cannot connect: {e}") + if r.status_code == 401: die("Auth failed (401). Check ARR_KEY_RADARR.") + if r.status_code >= 400: + try: d = r.json() + except: d = r.text[:200] + die(f"API error ({r.status_code}): {d}") + return r.json() + def _post(self, path, data): + url = f"{self.server}/api/v3{path}" + if self.dry_run: return {"dry_run":True, "url":url, "json":data} + if not self.key: die("ARR_KEY_RADARR not set.") + try: + r = requests.post(url, json=data, headers={"X-Api-Key": self.key, "Content-Type":"application/json"}, timeout=30) + except ConnectionError as e: die(f"Cannot connect: {e}") + if r.status_code >= 400: + try: d = r.json() + except: d = r.text[:200] + die(f"API error ({r.status_code}): {d}") + return r.json() + def status(self): return self._get("/system/status") + def movies(self): return self._get("/movie") + def movie(self, mid): return self._get(f"/movie/{mid}") + def lookup(self, term): return self._get("/movie/lookup", {"term": term}) + def collections(self): return self._get("/collection") + def calendar(self, start="", end=""): + p = {} + if start: p["start"] = start + if end: p["end"] = end + return self._get("/calendar", p) + def queue(self, page=1, limit=20): return self._get("/queue", {"page":page, "pageSize":limit}) + def history(self, page=1, limit=20): return self._get("/history", {"page":page, "pageSize":limit}) + def add_movie(self, tmdb_id, quality_profile=4, root_folder_path="", monitored=True, search=True): + return self._post("/movie", {"tmdbId":tmdb_id, "qualityProfileId":quality_profile, + "rootFolderPath":root_folder_path, "monitored":monitored, + "addOptions":{"searchForMovie":search}}) + +def cmd_status(client, args): + if client.dry_run: return emit("[dry-run] Get system status", {"dry_run":True}) + d = client.status() or {} + emit(f"🖥️ Radarr v{d.get('version','?')} OS: {d.get('osName','?')} DB: {d.get('databaseVersion','?')}", + {"version":d.get("version"),"osName":d.get("osName")}) + +def cmd_movies(client, args): + p = argparse.ArgumentParser(prog="radarr-cli movies") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--status", help="Filter by status (released, inCinemas, announced)") + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit("[dry-run] List movies", {"dry_run":True}) + data = client.movies() or [] + if parsed.status: data = [m for m in data if m.get("status") == parsed.status] + data = data[:parsed.limit] + if not data: return emit("No movies found.", {"movies":[]}) + lines, out = [], [] + for m in data: + title = m.get("title","?"); year = m.get("year",""); had = m.get("hasFile",False) + status = m.get("status","?"); mon = m.get("monitored",False) + icon = "✅" if had else ("👁️" if mon else "🚫") + lines.append(f" {icon} {title:45} ({year}) [{status}]") + out.append({"title":title,"year":year,"hasFile":had,"monitored":mon,"status":status,"id":m.get("id"),"tmdbId":m.get("tmdbId")}) + emit(f"{len(data)} movie(s):\n"+"\n".join(lines), {"movies":out}) + +def cmd_lookup(client, args): + p = argparse.ArgumentParser(prog="radarr-cli lookup") + p.add_argument("--term", "-t", required=True) + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit(f"[dry-run] Lookup: {parsed.term}", {"dry_run":True}) + data = client.lookup(parsed.term) or [] + if not data: return emit("No results.", {"results":[]}) + lines, out = [], [] + for m in data[:20]: + t = m.get("title","?"); y = m.get("year",""); s = m.get("status","?"); tid = m.get("tmdbId","?") + lines.append(f" {t:45} ({y}) [{s}] tmdb={tid}") + out.append({"title":t,"year":y,"status":s,"tmdbId":tid,"overview":m.get("overview","")[:120]}) + emit(f"{len(data)} result(s):\n"+"\n".join(lines), {"results":out}) + +def cmd_calendar(client, args): + if client.dry_run: return emit("[dry-run] Get calendar", {"dry_run":True}) + data = client.calendar() or [] + if not data: return emit("No upcoming.", {"movies":[]}) + lines, out = [], [] + for m in data[:30]: + t = m.get("title","?"); rd = (m.get("releaseDate") or "")[:10] + id = m.get("tmdbId",""); lines.append(f" {rd} {t:45} tmdb={id}") + out.append({"title":t,"releaseDate":rd,"tmdbId":id}) + emit(f"Upcoming:\n"+"\n".join(lines), {"movies":out}) + +def cmd_collections(client, args): + if client.dry_run: return emit("[dry-run] List collections", {"dry_run":True}) + data = client.collections() or [] + if not data: return emit("No collections.", {"collections":[]}) + lines, out = [], [] + for c in data: + name = c.get("name","?"); movies = c.get("movieCount",0); monitored = c.get("monitored",False) + lines.append(f" {name:45} {movies} movies {'✅' if monitored else '🚫'}") + out.append({"name":name,"movieCount":movies,"monitored":monitored,"id":c.get("id")}) + emit(f"{len(data)} collection(s):\n"+"\n".join(lines), {"collections":out}) + +def main(): + global GLOBAL_FLAGS, QUIET + GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv) + if GLOBAL_FLAGS.get("quiet"): QUIET = True + if GLOBAL_FLAGS.get("json"): warnings.simplefilter("ignore") + parser = argparse.ArgumentParser(prog="radarr-cli", description="Radarr movie management.", + epilog="Set ARR_SERVER_RADARR and ARR_KEY_RADARR.") + sub = parser.add_subparsers(dest="command") + sub.add_parser("status") + p_movies = sub.add_parser("movies", help="List movies") + p_movies.add_argument("--limit", type=int, default=50) + p_movies.add_argument("--status", help="Filter by status") + sub.add_parser("lookup", help="Search for movies").add_argument("--term","-t",required=True) + sub.add_parser("calendar") + sub.add_parser("collections") + args = parser.parse_args(filtered_argv[1:]) + if not args.command: parser.print_help(); sys.exit(1) + client = RadarrClient(dry_run=GLOBAL_FLAGS.get("dry_run",False)) + handlers = {"status":cmd_status,"movies":cmd_movies,"lookup":cmd_lookup,"calendar":cmd_calendar,"collections":cmd_collections} + h = handlers.get(args.command) + if not h: parser.print_help(); sys.exit(1) + r = filtered_argv[filtered_argv.index(args.command)+1:] + h(client, r) + +if __name__ == "__main__": main() diff --git a/arr-cli/scripts/sonarr-cli b/arr-cli/scripts/sonarr-cli new file mode 100755 index 0000000..3e8d55a --- /dev/null +++ b/arr-cli/scripts/sonarr-cli @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""sonarr-cli — Sonarr TV series management from the terminal.""" + +import argparse, json, os, sys, warnings +from typing import Any, Dict, List, Optional, Tuple +warnings.simplefilter("ignore") +import requests + +ENV_SERVER = os.getenv("ARR_SERVER_SONARR", "http://localhost:8989") +ENV_KEY = os.getenv("ARR_KEY_SONARR", "") + +QUIET = False +GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} +def log(m): global QUIET; G = GLOBAL_FLAGS; (not QUIET and not G.get("json")) and print(m) +def warn(m): print(f"Warning: {m}", file=sys.stderr) +def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c) +def emit(h, d): + if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str)) + else: print(h) + +def _preparse(argv): + BOOLS = {"--json","--dry-run","--force","--quiet","--verbose"} + f, fl = {}, [argv[0]] + i = 1 + while i < len(argv): + a = argv[i] + if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1 + elif a in ("--help","-h"): return f, argv + elif a == "--": fl.extend(argv[i:]); break + else: fl.append(a); i += 1 + return f, fl + +class SonarrClient: + def __init__(self, server="", key="", dry_run=False): + self.server = (server or ENV_SERVER).rstrip("/"); self.key = key or ENV_KEY; self.dry_run = dry_run + def _get(self, path, params=None): + url = f"{self.server}/api/v3{path}" + if self.dry_run: return {"dry_run":True, "url":url, "params":params} + if not self.key: die("ARR_KEY_SONARR not set. Get one from Sonarr → Settings → General → API Key.") + try: + r = requests.get(url, params=params, headers={"X-Api-Key": self.key, "Accept":"application/json"}, timeout=30) + except ConnectionError as e: die(f"Cannot connect: {e}") + if r.status_code == 401: die("Auth failed (401). Check ARR_KEY_SONARR.") + if r.status_code >= 400: + try: d = r.json() + except: d = r.text[:200] + die(f"API error ({r.status_code}): {d}") + return r.json() + def _post(self, path, data): + url = f"{self.server}/api/v3{path}" + if self.dry_run: return {"dry_run":True, "url":url, "json":data} + if not self.key: die("ARR_KEY_SONARR not set.") + try: + r = requests.post(url, json=data, headers={"X-Api-Key":self.key,"Content-Type":"application/json"}, timeout=30) + except ConnectionError as e: die(f"Cannot connect: {e}") + if r.status_code >= 400: + try: d = r.json() + except: d = r.text[:200] + die(f"API error ({r.status_code}): {d}") + return r.json() + def status(self): return self._get("/system/status") + def series_list(self): return self._get("/series") + def series(self, sid): return self._get(f"/series/{sid}") + def lookup(self, term): return self._get("/series/lookup", {"term": term}) + def episodes(self, series_id): return self._get("/episode", {"seriesId": series_id}) + def episode_files(self, series_id): return self._get("/episodefile", {"seriesId": series_id}) + def calendar(self, start="", end=""): + p = {} + if start: p["start"] = start + if end: p["end"] = end + return self._get("/calendar", p) + def queue(self, page=1, limit=20): return self._get("/queue", {"page":page, "pageSize":limit}) + def history(self, page=1, limit=20): return self._get("/history", {"page":page, "pageSize":limit}) + def wanted_missing(self, page=1, limit=20): return self._get("/wanted/missing", {"page":page, "pageSize":limit}) + def add_series(self, tvdb_id, quality_profile=4, root_folder_path="", monitored=True, search=True): + return self._post("/series", {"tvdbId":tvdb_id, "qualityProfileId":quality_profile, + "rootFolderPath":root_folder_path, "monitored":monitored, + "addOptions":{"searchForMissingEpisodes":search}}) + +def cmd_status(client, args): + if client.dry_run: return emit("[dry-run] Get system status", {"dry_run":True}) + d = client.status() or {} + emit(f"🖥️ Sonarr v{d.get('version','?')} OS: {d.get('osName','?')} DB: {d.get('databaseVersion','?')}", + {"version":d.get("version"),"osName":d.get("osName")}) + +def cmd_series(client, args): + p = argparse.ArgumentParser(prog="sonarr-cli series") + p.add_argument("--limit", type=int, default=50) + p.add_argument("--status", help="Filter by status (continuing, ended, upcoming)") + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit("[dry-run] List series", {"dry_run":True}) + data = client.series_list() or [] + if parsed.status: data = [s for s in data if s.get("status") == parsed.status] + data = data[:parsed.limit] + if not data: return emit("No series found.", {"series":[]}) + lines, out = [], [] + for s in data: + title = s.get("title","?"); year = s.get("year",""); net = s.get("network","") + status = s.get("status","?"); mon = s.get("monitored",False); seasons = len(s.get("seasons",[])) + icon = "✅" if s.get("statistics",{}).get("episodeCount",0) > 0 else ("👁️" if mon else "🚫") + net_str = f" [{net}]" if net else "" + lines.append(f" {icon} {title:40} ({year}){net_str} [{status}] {seasons} seasons") + out.append({"title":title,"year":year,"network":net,"status":status,"monitored":mon,"seasons":seasons, + "id":s.get("id"),"tvdbId":s.get("tvdbId")}) + emit(f"{len(data)} series:\n"+"\n".join(lines), {"series":out}) + +def cmd_lookup(client, args): + p = argparse.ArgumentParser(prog="sonarr-cli lookup") + p.add_argument("--term","-t",required=True) + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit(f"[dry-run] Lookup: {parsed.term}", {"dry_run":True}) + data = client.lookup(parsed.term) or [] + if not data: return emit("No results.", {"results":[]}) + lines, out = [], [] + for s in data[:20]: + t = s.get("title","?"); y = s.get("year",""); net = s.get("network",""); st = s.get("status","?") + tvdb = s.get("tvdbId","?"); net_str = f" [{net}]" if net else "" + lines.append(f" {t:40} ({y}){net_str} [{st}] tvdb={tvdb}") + out.append({"title":t,"year":y,"network":net,"status":st,"tvdbId":tvdb,"overview":s.get("overview","")[:120]}) + emit(f"{len(data)} result(s):\n"+"\n".join(lines), {"results":out}) + +def cmd_episodes(client, args): + p = argparse.ArgumentParser(prog="sonarr-cli episodes") + p.add_argument("--series-id", type=int, required=True) + p.add_argument("--limit", type=int, default=50) + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit("[dry-run] List episodes", {"dry_run":True}) + data = client.episodes(parsed.series_id) or [] + data = data[:parsed.limit] + if not data: return emit("No episodes.", {"episodes":[]}) + title = data[0].get("seriesTitle","?") + lines, out = [], [] + for e in data: + ep = e.get("episodeNumber","?"); sn = e.get("seasonNumber","?") + t = e.get("title","?"); had = e.get("hasFile",False); mon = e.get("monitored",False) + air = (e.get("airDate") or "?") + icon = "✅" if had else ("👁️" if mon else "🚫") + lines.append(f" {icon} S{sn:02d}E{ep:02d} {t:45} airs {air}") + out.append({"seriesTitle":title,"seasonNumber":sn,"episodeNumber":ep,"title":t,"hasFile":had,"monitored":mon,"airDate":air}) + emit(f"{title} — {len(data)} episodes:\n"+"\n".join(lines), {"episodes":out}) + +def cmd_calendar(client, args): + if client.dry_run: return emit("[dry-run] Get calendar", {"dry_run":True}) + data = client.calendar() or [] + if not data: return emit("No upcoming.", {"episodes":[]}) + lines, out = [], [] + for e in data[:30]: + t = e.get("seriesTitle","?"); sn = e.get("seasonNumber","?"); ep = e.get("episodeNumber","?") + air = (e.get("airDate") or "?"); title = e.get("title","") + lines.append(f" {air} {t:40} S{sn:02d}E{ep:02d} — {title}") + out.append({"seriesTitle":t,"seasonNumber":sn,"episodeNumber":ep,"airDate":air,"title":title}) + emit(f"Upcoming:\n"+"\n".join(lines), {"episodes":out}) + +def cmd_wanted(client, args): + p = argparse.ArgumentParser(prog="sonarr-cli wanted") + p.add_argument("--limit", type=int, default=20) + parsed, _ = p.parse_known_args(args) + if client.dry_run: return emit("[dry-run] List wanted", {"dry_run":True}) + data = client.wanted_missing(limit=parsed.limit) or {} + records = data.get("records",[]) + if not records: return emit("No missing episodes.", {"wanted":[]}) + lines, out = [], [] + for e in records: + t = e.get("seriesTitle","?"); sn = e.get("seasonNumber","?"); ep = e.get("episodeNumber","?") + title = e.get("title",""); air = (e.get("airDate") or "?") + lines.append(f" {t:40} S{sn:02d}E{ep:02d} — {title} airs {air}") + out.append({"seriesTitle":t,"seasonNumber":sn,"episodeNumber":ep,"title":title,"airDate":air}) + total = data.get("totalRecords", len(records)) + emit(f"{total} missing episode(s):\n"+"\n".join(lines), {"total":total,"wanted":out}) + +def main(): + global GLOBAL_FLAGS, QUIET + GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv) + if GLOBAL_FLAGS.get("quiet"): QUIET = True + if GLOBAL_FLAGS.get("json"): warnings.simplefilter("ignore") + parser = argparse.ArgumentParser(prog="sonarr-cli", description="Sonarr TV series management.", + epilog="Set ARR_SERVER_SONARR and ARR_KEY_SONARR.") + sub = parser.add_subparsers(dest="command") + sub.add_parser("status") + p_series = sub.add_parser("series", help="List series") + p_series.add_argument("--limit", type=int, default=50) + p_series.add_argument("--status", help="Filter by status") + p_lookup = sub.add_parser("lookup", help="Search for series") + p_lookup.add_argument("--term","-t",required=True) + p_eps = sub.add_parser("episodes", help="List episodes") + p_eps.add_argument("--series-id",type=int,required=True) + p_eps.add_argument("--limit",type=int,default=50) + sub.add_parser("calendar") + sub.add_parser("wanted").add_argument("--limit",type=int,default=20) + args = parser.parse_args(filtered_argv[1:]) + if not args.command: parser.print_help(); sys.exit(1) + client = SonarrClient(dry_run=GLOBAL_FLAGS.get("dry_run",False)) + handlers = {"status":cmd_status,"series":cmd_series,"lookup":cmd_lookup,"episodes":cmd_episodes,"calendar":cmd_calendar,"wanted":cmd_wanted} + h = handlers.get(args.command) + if not h: parser.print_help(); sys.exit(1) + r = filtered_argv[filtered_argv.index(args.command)+1:] + h(client, r) + +if __name__ == "__main__": main()