chore: move arr skills to private repository

Signed-off-by: Magnus Hedemark <magnus919@pm.me>
This commit is contained in:
Magnus Hedemark
2026-07-20 22:44:28 -04:00
parent bd66202e06
commit 2844f1a752
22 changed files with 0 additions and 2729 deletions
-12
View File
@@ -26,10 +26,6 @@ Reference for the Agent Skills open format itself — directory structure, front
Design, document, review, and evolve consumer-facing HTTP, GraphQL, RPC, event, webhook, and streaming interfaces. Covers consumer jobs, domain semantics, contracts, failure behavior, compatibility, deprecation, migration, and deployed-boundary verification.
### [arr-cli](arr-cli/SKILL.md)
Radarr and Sonarr media library management. Two CLIs (`radarr-cli` for movies, `sonarr-cli` for TV series) with one shared skill wrapper. List movies and series, search for additions, check calendars and wanted/missing episodes. Separate API keys per app.
### [artifact-pyramids](artifact-pyramids/SKILL.md)
Structure durable research work so people and agents can start with conclusions, then inspect the analysis and underlying evidence only when needed.
@@ -178,10 +174,6 @@ Build multi-agent AI systems with LangGraph — the low-level orchestration fram
Last.fm music data API from the terminal. Lookup user listening history, get artist/album/track metadata, discover similar music via collaborative filtering, explore global and per-country charts, search, manage tags, and scrobble listening events. API key from last.fm/api/account/create (free). Includes a music discovery pipeline for turning liked tracks into recommendations.
### [lidarr-cli](lidarr-cli/SKILL.md)
Manage your Lidarr music library from the terminal — search and browse artists and albums, add new artists, check calendars, view queue and download history.
### [linear](linear/SKILL.md)
Work with Linear teams, projects, cycles, issues, comments, workflow transitions, and documents using a small, dependency-free GraphQL CLI with bounded reads, dry-run previews, and focused reference guidance.
@@ -237,10 +229,6 @@ Discover product requirements from human stakeholders — map who to talk to, as
Product management frameworks for translating validated evidence into prioritized backlogs, documented decisions, specifications, and stakeholder communications. Covers RICE scoring, MoSCoW prioritization, opportunity solution trees, decision logs, spec drafting, and audience-specific stakeholder communication. Ships 7 reference files covering each framework and a source index, plus 2 fillable templates (decision log and spec). Picks up where product-discovery ends.
### [prowlarr-cli](prowlarr-cli/SKILL.md)
Manage your Prowlarr indexer hub from the terminal — list and inspect indexers, view query/grab statistics, check health, manage connected *arr applications, and test indexer connectivity.
### [pydanticai](pydanticai/SKILL.md)
Build production-grade AI agents and graph-based state machines with PydanticAI and PydanticGraph. Covers agent creation, function tools with RunContext dependencies, structured output validation, streaming (text/events/graph nodes), a 20+ capability plugin system with on-demand loading (Thinking, WebSearch, MCP, Hooks, etc.), 16 model providers with FallbackModel and concurrency limiting, multi-agent delegation and programmatic hand-off, comprehensive testing with TestModel/FunctionModel, and the PydanticEvals evaluation framework. Includes the full PydanticGraph API — both BaseNode (class-based) and GraphBuilder (function-based) with parallel map/broadcast operations, joins with reducers, decisions, Mermaid rendering, and step-by-step execution. Ships 8 reference files covering core agents, capabilities/hooks, graph, models/output, patterns/integrations, testing/evals, worked examples, and an API surface quick reference.
-38
View File
@@ -1,38 +0,0 @@
# Radarr & Sonarr Media Library Management
A unified CLI skill for managing movies (Radarr) and TV series (Sonarr) from the terminal. Two CLIs, one skill wrapper.
## Why Install This Skill
When your agent loads this skill, it can **manage your entire *arr media library** without needing a web browser. That means:
- **Ask what's in your library** — list movies, TV series, recently added content
- **Add new media** — search for titles by name, then add them with the right quality profile
- **Track upcoming releases** — check calendars for scheduled releases
- **Monitor downloads** — view queue, download history, and wanted/missing episodes
- **Troubleshoot** — check quality profiles, root folders, and system status
## What You Get
| Directory | Purpose |
|-----------|---------|
| `SKILL.md` | Complete command reference with trigger table for every operation |
| `scripts/radarr-cli` | CLI tool for Radarr movie management |
| `scripts/sonarr-cli` | CLI tool for Sonarr TV series management |
## Quick Start
```bash
export ARR_SERVER_RADARR="http://localhost:7878"
export ARR_KEY_RADARR="your-radarr-api-key"
export ARR_SERVER_SONARR="http://localhost:8989"
export ARR_KEY_SONARR="your-sonarr-api-key"
```
## Triggers
Load this skill when you hear "Radarr," "Sonarr," "the *arr stack," "add a movie," "find a TV show," "what's in my library," "upcoming releases," "download queue," or anything about media library management.
## Requirements
Python 3.8+ with `requests` library. API keys from each app's Settings → General.
-124
View File
@@ -1,124 +0,0 @@
---
name: arr-cli
description: Manage your Radarr (movies) and Sonarr (TV series) media library from
the terminal. Search and browse movies/series, add new content, check calendars,
view queue and download history, inspect quality profiles, and trigger searches.
Use when the user mentions Radarr, Sonarr, the *arr stack, adding a movie or series,
checking the library, finding something to watch, what's in the queue, download
history, upcoming releases, media server setup, or movie/TV automation.
license: MIT
compatibility: Python 3.8+ with `requests` library. Requires ARR_SERVER_RADARR and
ARR_KEY_RADARR (for Radarr) or ARR_SERVER_SONARR and ARR_KEY_SONARR (for Sonarr).
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.
## Quick Setup
```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.
## When to Use Which
| User says... | Load... |
|---|---|
| "list my movies", "what's in Radarr", "show me the library" | `radarr-cli movies [--limit N] [--status released\|inCinemas\|announced]` |
| "add this movie", "is this in Radarr", "search for a movie" | `radarr-cli lookup --term "title"` then `radarr-cli add --tmdb-id N --root /movies` |
| "what's coming up", "upcoming releases" | `radarr-cli calendar` or `sonarr-cli calendar` |
| "what's in the queue", "download progress" | `radarr-cli queue` or `sonarr-cli queue` |
| "show me history", "what was downloaded" | `radarr-cli history` or `sonarr-cli history` |
| "list my series", "what's on Sonarr" | `sonarr-cli series [--limit N] [--status continuing\|ended\|upcoming]` |
| "add this series", "find a TV show" | `sonarr-cli lookup --term "title"` then `sonarr-cli add --tvdb-id N --root /tv` |
| "what's missing", "wanted episodes" | `sonarr-cli wanted` |
| "check quality profiles", "what profiles exist" | `radarr-cli quality-profile` or `sonarr-cli quality-profile` |
| "search for a movie/series" (already added) | `radarr-cli search --movie-id N` or `sonarr-cli search --series-id N` |
| "where are my root folders", "storage paths" | `radarr-cli root-folder` or `sonarr-cli root-folder` |
| "what episodes are downloaded", "episode files" | `sonarr-cli episode-file --series-id N` |
## Quick Reference
**Global flags** (work anywhere in arg list): `--json`, `--dry-run`, `--force`, `--quiet`, `--verbose`
```bash
# System info
radarr-cli status
sonarr-cli status
# List with filters and JSON output (pipe to jq for scripting)
radarr-cli movies --limit 5 --status released --json | jq '.movies[].title'
sonarr-cli series --limit 5 --status continuing --json | jq '.series[].title'
# Discover and add a movie (always use TMDb ID, not title)
radarr-cli lookup --term "Dune"
radarr-cli add --tmdb-id 12345 --root /movies --quality-profile 4 --search
# Add with specific availability (for unreleased films)
radarr-cli add --tmdb-id 99999 --root /movies --quality-profile 5 --availability announced
# Discover and add a series (always use TVDb ID)
sonarr-cli lookup --term "Severance"
sonarr-cli add --tvdb-id 77526 --root /tv --quality-profile 4 --series-type Standard --search
# Add an anime series (flat episode storage, absolute numbering)
sonarr-cli add --tvdb-id 12345 --root /tv --quality-profile 4 --series-type Anime --no-season-folder --search
# Preview before adding (no side effects)
radarr-cli --dry-run add --tmdb-id 550 --root /movies --quality-profile 4
# Trigger a search for something already in your library
radarr-cli search --movie-id 5
sonarr-cli search --series-id 5
# Queue and history
radarr-cli queue --limit 10
radarr-cli history --limit 5 --event-type grabbed
# Discover root folder paths before adding
radarr-cli root-folder
sonarr-cli root-folder
# Quality profiles
radarr-cli quality-profile # list all
radarr-cli quality-profile 4 # inspect HD-1080p details with tier checkmarks
```
**IMPORTANT — lookup side effect:** `radarr-cli lookup --tmdb-id N` silently adds an unmonitored record. For pure existence checks, use `radarr-cli --json movies | jq '.[] | select(.tmdbId == N)'` instead.
**Default quality profile:** HD-1080p (id: 4). Override with `--quality-profile N`.
## Reference Files
| File | Contents |
|------|----------|
| `references/radarr-commands.md` | Full Radarr command reference with all flags and examples |
| `references/sonarr-commands.md` | Full Sonarr command reference with all flags and examples |
| `references/media-discovery.md` | Cross-referencing TMDb/Trakt discovery results against your library |
| `references/troubleshooting.md` | Pitfalls, FAQs, and when NOT to use |
## When NOT to Use
- **Media playback** — use Jellyfin for watching content. This CLI only manages the library.
- **First-time *arr setup** — this skill assumes Radarr/Sonarr are already installed and configured. See `radarr.video` or `sonarr.tv` for installation guides.
- **Bulk imports** — for large-scale library migrations, prefer Radarr/Sonarr's built-in import features or manual disk operations.
## First-Time Loading
1. Set the env vars above (API keys from each app's Settings → General)
2. Test with `radarr-cli status` — you should see version and OS info
3. Browse your library: `radarr-cli movies --limit 5`
4. For cross-reference workflows (discovering new content via TMDb/Trakt), see `references/media-discovery.md`
-84
View File
@@ -1,84 +0,0 @@
---
name: arr-cli/media-discovery
description: "Workflow for discovering new media via TMDb/Trakt APIs and cross-referencing against your Radarr/Sonarr library."
---
# Media Discovery & Cross-Reference
Use this reference when the user asks about upcoming movies, new releases, trending content, or finding something to watch. It covers discovering content through external APIs and checking whether it's already in your library.
## Prerequisites
- **TMDb API key** — Get one free at themoviedb.org → Settings → API
- **Trakt API key** (optional) — Get one at trakt.tv → Settings → Your API Apps
## Cross-Reference by ID
**Always use TMDb ID (movies) or TVDb ID (series), never title matching.** Title matching produces false negatives — titles in Radarr/Sonarr may differ slightly from TMDb/Trakt (punctuation, subtitle format, etc.).
### Check if a movie is already in Radarr
```bash
# Pure existence check — no side effects
radarr-cli --json movies | jq '.[] | select(.tmdbId == 1003596)'
# Returns the movie record if it exists, empty if not
# Fields: title, tmdbId, hasFile, monitored, status, year
```
**Do NOT use `radarr-cli lookup --tmdb-id N` for existence checks** — it silently adds an unmonitored record.
### Check if a series is already in Sonarr
```bash
sonarr-cli --json series | jq '.[] | select(.tvdbId == 77526)'
```
### Add a discovered movie
```bash
# Not in library → add
radarr-cli add --tmdb-id 1003596 --root /movies --quality-profile 4 --search
# Already exists but unmonitored → get Radarr internal ID, then PUT
# (See troubleshooting.md for the unmonitor-to-monitored recipe)
```
## TMDb Discovery Examples
```bash
# R-rated horror coming soon (genre 27 = Horror)
curl -s "https://api.themoviedb.org/3/discover/movie?with_genres=27&certification=R&sort_by=popularity.desc" \
-H "Authorization: Bearer $TMDB_ACCESS_TOKEN" | \
python3 -c "
import json,sys
data = json.load(sys.stdin)
for m in data.get('results',[]):
print(f\" {m['id']:8} {m['title']:45} ({m.get('release_date','?')[:4]})\")
"
```
## Trakt Discovery Examples
```bash
# Anticipated movies
curl -s "https://api.trakt.tv/movies/anticipated?limit=10" \
-H "Content-Type: application/json" \
-H "trakt-api-key: $TRAKT_CLIENT_ID" \
-H "trakt-api-version: 2" | \
python3 -c "
import json,sys
data = json.load(sys.stdin)
for m in data:
movie = m.get('movie',{})
print(f\" {movie.get('ids',{}).get('tmdb','?'):8} {movie.get('title','?'):45}\")
"
```
## Full Cross-Reference Workflow
1. **Discover** — Use TMDb or Trakt to find content (upcoming horror, anticipated movies, trending series)
2. **Extract IDs** — Each result includes TMDb ID (movies) or TVDb ID (series)
3. **Check library** — Filter `radarr-cli --json movies` or `sonarr-cli --json series` by that ID
4. **Add if missing** — Use `radarr-cli add --tmdb-id N` or `sonarr-cli add --tvdb-id N`
5. **Report** — Present results split into "already in library" and "added"
-124
View File
@@ -1,124 +0,0 @@
---
name: arr-cli/radarr-commands
description: "Full Radarr command reference for the arr-cli skill — all subcommands, flags, and extended examples."
---
# Radarr Commands — Full Reference
## System
```bash
radarr-cli status # version, OS, database
radarr-cli status --json # machine-readable
```
## Movies
```bash
radarr-cli movies # all movies
radarr-cli movies --limit 10 # top 10
radarr-cli movies --status released # filter: released, inCinemas, announced
radarr-cli movies --json # machine-readable (includes tmdbId for cross-ref)
```
**Icons:** ✅ = has file, 👁️ = monitored but missing, 🚫 = unmonitored.
## Lookup (search to add)
```bash
radarr-cli lookup --term "Dune" # search by title
radarr-cli lookup --term "Dune" --json # includes TMDb ID and overview
```
**⚠️ Side effect:** `lookup` silently adds an unmonitored record to Radarr. For pure existence checks, use `radarr-cli --json movies` and filter by `tmdbId`.
## Add
```bash
# Basic: add monitored with auto-search
radarr-cli add --tmdb-id 550 --root /movies --quality-profile 4 --search
# High quality, upcoming release (don't search yet)
radarr-cli add --tmdb-id 99999 --root /movies --quality-profile 5 --availability announced
# Add unmonitored (manual decision later)
radarr-cli add --tmdb-id 12345 --root /movies --unmonitored
# Preview without adding (no side effects)
radarr-cli --dry-run add --tmdb-id 550 --root /movies --quality-profile 4
```
| Flag | Default | Description |
|------|---------|-------------|
| `--tmdb-id` | required | TMDb movie ID (canonical key) |
| `--root` | required | Root folder path (discover with `root-folder`) |
| `--quality-profile` | 4 (HD-1080p) | Quality profile ID |
| `--unmonitored` | off | Add without monitoring (default: monitored) |
| `--search` | off | Trigger automatic search after adding |
| `--availability` | released | `announced`, `inCinemas`, or `released` |
## Quality Profiles
```bash
radarr-cli quality-profile # list all profiles with IDs
radarr-cli quality-profile 4 # inspect specific profile (cutoff, tier checkmarks)
radarr-cli --json quality-profile # machine-readable for scripting
```
Use `--json` output to find profile IDs programmatically.
## Calendar
```bash
radarr-cli calendar # next 14 days
radarr-cli calendar --json # machine-readable
```
## Queue
```bash
radarr-cli queue # current download queue (default: 20)
radarr-cli queue --limit 50 # more results
radarr-cli queue --json # machine-readable
```
## History
```bash
radarr-cli history # recent activity (default: 20)
radarr-cli history --limit 50 # more results
radarr-cli history --event-type grabbed # filter by event type
radarr-cli history --json # machine-readable
```
## Search (trigger on existing items)
```bash
radarr-cli search --movie-id 5 # trigger automatic search for movie (Radarr internal ID)
```
Returns a command object immediately — search runs async in the background.
## Root Folders
```bash
radarr-cli root-folder # list all root folders with free space
radarr-cli root-folder --json # machine-readable
```
## Collections
```bash
radarr-cli collections # list movie collections
radarr-cli collections --json # machine-readable with movie counts
```
## JSON Output
All commands accept `--json`. Pipe to `jq` for scripting:
```bash
radarr-cli --json movies | jq '.movies[] | {title, tmdbId, hasFile, monitored}'
radarr-cli --json queue | jq '.queue[] | {title, status, progress}'
radarr-cli --json history | jq '.history[] | {date, title, eventType}'
```
-139
View File
@@ -1,139 +0,0 @@
---
name: arr-cli/sonarr-commands
description: "Full Sonarr command reference for the arr-cli skill — all subcommands, flags, and extended examples."
---
# Sonarr Commands — Full Reference
## System
```bash
sonarr-cli status # version, OS, database
sonarr-cli status --json # machine-readable
```
## Series
```bash
sonarr-cli series # all series
sonarr-cli series --limit 10 # top 10
sonarr-cli series --status continuing # filter: continuing, ended, upcoming
sonarr-cli series --json # machine-readable (includes tvdbId for cross-ref)
```
**Icons:** ✅ = has episodes, 👁️ = monitored but empty, 🚫 = unmonitored.
## Lookup (search to add)
```bash
sonarr-cli lookup --term "Severance" # search by title
sonarr-cli lookup --term "Severance" --json # includes TVDb ID and overview
```
## Add
```bash
# Standard weekly show with auto-search
sonarr-cli add --tvdb-id 77526 --root /tv --quality-profile 4 --series-type Standard --search
# Daily show (talk show, news)
sonarr-cli add --tvdb-id 12345 --root /tv --quality-profile 4 --series-type Daily --search
# Anime with absolute numbering, flat episode storage
sonarr-cli add --tvdb-id 12345 --root /tv --quality-profile 4 --series-type Anime --no-season-folder --search
# Preview without adding
sonarr-cli --dry-run add --tvdb-id 77526 --root /tv
```
| Flag | Default | Description |
|------|---------|-------------|
| `--tvdb-id` | required | TVDb series ID (canonical key) |
| `--root` | required | Root folder path (discover with `root-folder`) |
| `--quality-profile` | 4 (HD-1080p) | Quality profile ID |
| `--series-type` | Standard | `Standard`, `Daily`, or `Anime` |
| `--unmonitored` | off | Add without monitoring (default: monitored) |
| `--search` | off | Search for missing episodes after adding |
| `--no-season-folder` | off | Flat storage (all episodes in series root) |
## Quality Profiles
```bash
sonarr-cli quality-profile # list all profiles with IDs
sonarr-cli quality-profile 4 # inspect specific profile (cutoff, tier checkmarks)
sonarr-cli --json quality-profile # machine-readable for scripting
```
## Episodes
```bash
sonarr-cli episodes --series-id 1 # all episodes
sonarr-cli episodes --series-id 1 --limit 10 # recent episodes
sonarr-cli episodes --series-id 1 --json # machine-readable
```
Get the Sonarr internal series ID from `sonarr-cli series --json`.
## Episode Files
```bash
sonarr-cli episode-file --series-id 1 # downloaded files with quality and size
sonarr-cli episode-file --series-id 1 --json # machine-readable
```
## Calendar
```bash
sonarr-cli calendar # upcoming episodes (next 14 days)
sonarr-cli calendar --json # machine-readable
```
## Wanted (Missing Episodes)
```bash
sonarr-cli wanted # missing episodes (default: 20)
sonarr-cli wanted --limit 50 # more results
sonarr-cli wanted --json # machine-readable with totalRecords
```
## Queue
```bash
sonarr-cli queue # current download queue (default: 20)
sonarr-cli queue --limit 50 # more results
sonarr-cli queue --json # machine-readable
```
## History
```bash
sonarr-cli history # recent activity (default: 20)
sonarr-cli history --limit 50 # more results
sonarr-cli history --event-type grabbed # filter by event type
sonarr-cli history --json # machine-readable
```
## Search (trigger on existing items)
```bash
sonarr-cli search --series-id 5 # trigger automatic search for series (Sonarr internal ID)
```
Returns a command object immediately — search runs async in the background.
## Root Folders
```bash
sonarr-cli root-folder # list all root folders with free space
sonarr-cli root-folder --json # machine-readable
```
## JSON Output
All commands accept `--json`. Pipe to `jq` for scripting:
```bash
sonarr-cli --json series | jq '.series[] | {title, tvdbId, monitored, seasons}'
sonarr-cli --json wanted | jq '.wanted[] | {seriesTitle, seasonNumber, episodeNumber}'
sonarr-cli --json queue | jq '.queue[] | {title, status, progress}'
```
-71
View File
@@ -1,71 +0,0 @@
---
name: arr-cli/troubleshooting
description: "Common pitfalls, FAQs, and guidance on when not to use the arr-cli skill."
---
# Troubleshooting & FAQs
## Common Pitfalls
### Looking up adds an unmonitored record
`radarr-cli lookup --tmdb-id N` silently creates an unmonitored record. This is a Radarr API design — there's no pure "lookup without adding." For existence checks, filter the movie list instead:
```bash
# ✅ Correct — no side effects
radarr-cli --json movies | jq '.[] | select(.tmdbId == 12345)'
# ❌ Wrong — creates phantom unmonitored record
radarr-cli lookup --tmdb-id 12345
```
### Adding requires a root folder path
`radarr-cli add --root /movies` — the root folder must be an exact path known to Radarr. Discover available paths:
```bash
radarr-cli root-folder
```
### Commands appear to hang on large libraries
The `movie` and `series` endpoints return the full collection — no server-side pagination. On libraries with 2500+ movies, this can take a few seconds. Use `--limit N` for faster results:
```bash
radarr-cli movies --limit 10
```
### JSON output mixed with warning lines
Occasionally Python deprecation warnings can bleed into `--json` output. If `json.loads()` fails, filter lines that start with `[` or `{` before parsing.
### Sonarr wanted endpoint is paginated
The `wanted` endpoint returns a `totalRecords` field. Use `--limit` to control page size. Results are capped at the server's max page size.
## When Not to Use
- **Media playback** — this CLI manages the library, not plays content. Use Jellyfin.
- **First-time *arr setup** — assumes Radarr/Sonarr are already installed and configured. See `radarr.video` or `sonarr.tv`.
- **Bulk library migration** — for large-scale imports, use Radarr/Sonarr's built-in tools.
- **Streaming service status** — this won't tell you if a streaming service is down.
## FAQ
**Q: Why use --tmdb-id instead of a title search?**
A: Titles can differ between TMDb and Radarr (special characters, subtitle formatting, year disambiguation). The TMDb ID is the canonical key shared by both systems.
**Q: Can I add a movie that's not released yet?**
A: Yes. Use `--availability announced` for unreleased films, `--availability inCinemas` for current theatrical releases. Don't use `--search` for unreleased content — there's nothing to search for.
**Q: How do I find a movie's TMDb ID?**
A: Use `radarr-cli lookup --term "title" --json` — the results include `tmdbId`. Or search themoviedb.org directly — the ID is in the URL.
**Q: How do I find a series' TVDb ID?**
A: Use `sonarr-cli lookup --term "title" --json` — results include `tvdbId`. Or search thetvdb.com.
**Q: What does quality profile 4 vs 5 mean?**
A: Profile IDs vary by setup. Use `radarr-cli quality-profile` to list all profiles with their names:
- 4 is typically HD-1080p
- 5 is typically Ultra-HD (4K)
- Run the command to see your actual configuration
-273
View File
@@ -1,273 +0,0 @@
#!/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 quality_profiles(self): return self._get("/qualityprofile")
def quality_profile(self, pid): return self._get(f"/qualityprofile/{pid}")
def root_folders(self): return self._get("/rootfolder")
def command(self, name, **kwargs):
return self._post("/command", {"name":name, **kwargs})
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 cmd_queue(client, args):
p = argparse.ArgumentParser(prog="radarr-cli queue")
p.add_argument("--limit", type=int, default=20)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List queue", {"dry_run":True})
data = client.queue(limit=parsed.limit) or []
if not data: return emit("Queue is empty.", {"queue":[]})
lines, out = [], []
for item in data:
title = item.get("title","?"); status = item.get("status","?")
pct = item.get("sizeleft",0) and item.get("size",1) and f"{100-item.get('sizeleft',0)*100//item.get('size',1):2d}%" or "?"
lines.append(f" {title:45} [{status:10}] {pct}")
out.append({"title":title,"status":status,"progress":pct})
emit(f"{len(data)} in queue:\n"+"\n".join(lines), {"queue":out})
def cmd_history(client, args):
p = argparse.ArgumentParser(prog="radarr-cli history")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--event-type", help="Filter by event type (grabbed, importFailed, etc.)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List history", {"dry_run":True})
data = client.history(limit=parsed.limit) or {}
records = data.get("records",[]) if isinstance(data,dict) else data[:parsed.limit]
if not records: return emit("No history.", {"history":[]})
lines, out = [], []
for e in records[:parsed.limit]:
title = e.get("movie",{}).get("title","?") if isinstance(e.get("movie"),dict) else e.get("title","?")
evt = e.get("eventType","?")
date = (e.get("date") or "")[:10]
lines.append(f" {date} {title:45} {evt}")
out.append({"date":date,"title":title,"eventType":evt})
emit(f"History:\n"+"\n".join(lines), {"history":out})
def cmd_add(client, args):
p = argparse.ArgumentParser(prog="radarr-cli add")
p.add_argument("--tmdb-id", type=int, required=True, help="TMDb ID")
p.add_argument("--root", required=True, help="Root folder path (e.g. /movies)")
p.add_argument("--quality-profile", type=int, default=4, help="Quality profile ID (default: 4 = HD-1080p)")
p.add_argument("--unmonitored", action="store_true", help="Add unmonitored (default: monitored)")
p.add_argument("--search", action="store_true", help="Search for movie after adding")
p.add_argument("--availability", default="released", choices=["announced","inCinemas","released"],
help="Minimum availability (default: released)")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Add movie tmdb={parsed.tmdb_id} profile={parsed.quality_profile} root={parsed.root}",
{"dry_run":True, "tmdbId":parsed.tmdb_id, "qualityProfileId":parsed.quality_profile,
"rootFolderPath":parsed.root, "monitored":not parsed.unmonitored,
"minimumAvailability":parsed.availability, "searchForMovie":parsed.search})
data = client.add_movie(parsed.tmdb_id, quality_profile=parsed.quality_profile,
root_folder_path=parsed.root, monitored=not parsed.unmonitored,
search=parsed.search)
emit(f"Added: #{data.get('id')} {data.get('title')} ({data.get('year')})",
{"id":data.get("id"), "title":data.get("title"), "year":data.get("year")})
def cmd_quality_profile(client, args):
p = argparse.ArgumentParser(prog="radarr-cli quality-profile")
p.add_argument("id", nargs="?", type=int, help="Profile ID (omit to list all)")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Quality profile: {parsed.id or 'all'}", {"dry_run":True})
if parsed.id:
qp = client.quality_profile(parsed.id) or {}
items = qp.get("items",[])
lines = [f" {qp.get('name','?')} (id={qp.get('id','?')}) cutoff={qp.get('cutoff',{}).get('name','?')}"]
for item in items:
name = item.get("quality",{}).get("name","?")
allowed = "✓" if item.get("allowed") else "✗"
lines.append(f" {allowed} {name}")
emit("\n".join(lines), qp)
else:
profiles = client.quality_profiles() or []
lines = [f" {p.get('id')}: {p.get('name','?')} cutoff={p.get('cutoff',{}).get('name','?')}" for p in profiles]
emit(f"{len(profiles)} quality profile(s):\n"+"\n".join(lines), {"profiles":profiles})
def cmd_search(client, args):
p = argparse.ArgumentParser(prog="radarr-cli search")
p.add_argument("--movie-id", type=int, required=True, help="Radarr internal movie ID")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Search movie id={parsed.movie_id}", {"dry_run":True, "name":"MoviesSearch", "movieIds":[parsed.movie_id]})
data = client.command("MoviesSearch", movieIds=[parsed.movie_id])
emit(f"Search triggered: command #{data.get('id')} — {data.get('status','?')}",
{"id":data.get("id"), "status":data.get("status"), "name":data.get("name")})
def cmd_root_folder(client, args):
if client.dry_run: return emit("[dry-run] List root folders", {"dry_run":True})
data = client.root_folders() or []
if not data: return emit("No root folders configured.", {"rootFolders":[]})
lines = [f" {r.get('path','?')} ({r.get('freeSpace',0)//1073741824}GB free)" for r in data]
emit(f"{len(data)} root folder(s):\n"+"\n".join(lines), {"rootFolders":data})
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")
sub.add_parser("queue")
sub.add_parser("history")
p_add = sub.add_parser("add", help="Add a movie to library")
p_add.add_argument("--tmdb-id", type=int, required=True)
p_add.add_argument("--root", required=True)
p_add.add_argument("--quality-profile", type=int, default=4)
p_add.add_argument("--unmonitored", action="store_true")
p_add.add_argument("--search", action="store_true")
p_add.add_argument("--availability", default="released", choices=["announced","inCinemas","released"])
sub.add_parser("quality-profile", help="List or inspect quality profiles").add_argument("id", nargs="?", type=int)
sub.add_parser("search", help="Trigger automatic search for a movie").add_argument("--movie-id", type=int, required=True)
sub.add_parser("root-folder", help="List configured root folders")
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,"queue":cmd_queue,"history":cmd_history,
"add":cmd_add,"quality-profile":cmd_quality_profile,"search":cmd_search,"root-folder":cmd_root_folder}
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()
-330
View File
@@ -1,330 +0,0 @@
#!/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,
series_type="Standard", season_folder=True):
return self._post("/series", {"tvdbId":tvdb_id, "qualityProfileId":quality_profile,
"rootFolderPath":root_folder_path, "monitored":monitored,
"seriesType":series_type, "seasonFolder":season_folder,
"addOptions":{"searchForMissingEpisodes":search}})
def quality_profiles(self): return self._get("/qualityprofile")
def quality_profile(self, pid): return self._get(f"/qualityprofile/{pid}")
def root_folders(self): return self._get("/rootfolder")
def command(self, name, **kwargs):
return self._post("/command", {"name":name, **kwargs})
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 cmd_queue(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli queue")
p.add_argument("--limit", type=int, default=20)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List queue", {"dry_run":True})
data = client.queue(limit=parsed.limit) or []
if not data: return emit("Queue is empty.", {"queue":[]})
lines, out = [], []
for item in data:
title = item.get("title","?"); status = item.get("status","?")
pct = item.get("sizeleft",0) and item.get("size",1) and f"{100-item.get('sizeleft',0)*100//item.get('size',1):2d}%" or "?"
lines.append(f" {title:45} [{status:10}] {pct}")
out.append({"title":title,"status":status,"progress":pct})
emit(f"{len(data)} in queue:\n"+"\n".join(lines), {"queue":out})
def cmd_history(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli history")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--event-type", help="Filter by event type")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List history", {"dry_run":True})
data = client.history(limit=parsed.limit) or {}
records = data.get("records",[]) if isinstance(data,dict) else data[:parsed.limit]
if not records: return emit("No history.", {"history":[]})
lines, out = [], []
for e in records[:parsed.limit]:
title = e.get("series",{}).get("title","?") if isinstance(e.get("series"),dict) else e.get("seriesTitle","?")
evt = e.get("eventType","?"); date = (e.get("date") or "")[:10]
lines.append(f" {date} {title:45} {evt}")
out.append({"date":date,"title":title,"eventType":evt})
emit(f"History:\n"+"\n".join(lines), {"history":out})
def cmd_add(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli add")
p.add_argument("--tvdb-id", type=int, required=True, help="TVDb ID")
p.add_argument("--root", required=True, help="Root folder path (e.g. /tv)")
p.add_argument("--quality-profile", type=int, default=4, help="Quality profile ID (default: 4)")
p.add_argument("--series-type", default="Standard", choices=["Standard","Daily","Anime"], help="Series type")
p.add_argument("--unmonitored", action="store_true", help="Add unmonitored")
p.add_argument("--search", action="store_true", help="Search for episodes after adding")
p.add_argument("--no-season-folder", action="store_true", help="Flat episode storage")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Add series tvdb={parsed.tvdb_id} type={parsed.series_type}",
{"dry_run":True, "tvdbId":parsed.tvdb_id, "qualityProfileId":parsed.quality_profile,
"rootFolderPath":parsed.root, "monitored":not parsed.unmonitored,
"seriesType":parsed.series_type, "seasonFolder":not parsed.no_season_folder})
data = client.add_series(parsed.tvdb_id, quality_profile=parsed.quality_profile,
root_folder_path=parsed.root, monitored=not parsed.unmonitored,
search=parsed.search, series_type=parsed.series_type,
season_folder=not parsed.no_season_folder)
emit(f"Added: #{data.get('id')} {data.get('title')} ({data.get('year')})",
{"id":data.get("id"), "title":data.get("title"), "year":data.get("year")})
def cmd_quality_profile(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli quality-profile")
p.add_argument("id", nargs="?", type=int, help="Profile ID (omit to list all)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] Quality profile: {parsed.id or 'all'}", {"dry_run":True})
if parsed.id:
qp = client.quality_profile(parsed.id) or {}
items = qp.get("items",[])
lines = [f" {qp.get('name','?')} (id={qp.get('id','?')}) cutoff={qp.get('cutoff',{}).get('name','?')}"]
for item in items:
name = item.get("quality",{}).get("name","?")
allowed = "✓" if item.get("allowed") else "✗"
lines.append(f" {allowed} {name}")
emit("\n".join(lines), qp)
else:
profiles = client.quality_profiles() or []
lines = [f" {p.get('id')}: {p.get('name','?')}" for p in profiles]
emit(f"{len(profiles)} quality profile(s):\n"+"\n".join(lines), {"profiles":profiles})
def cmd_episode_files(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli episode-file")
p.add_argument("--series-id", type=int, required=True, help="Sonarr series ID")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List episode files for series {parsed.series_id}", {"dry_run":True})
data = client.episode_files(parsed.series_id) or []
if not data: return emit("No episode files found.", {"episodeFiles":[]})
lines, out = [], []
for f in data:
sn = f.get("seasonNumber","?"); ep = f.get("episodeNumbers",[None])[0]
qual = f.get("quality",{}).get("quality",{}).get("name","?")
size = f.get("size",0)//1048576
lines.append(f" S{sn:02d}E{ep:02d} — {qual:20} {size}MB")
out.append({"seasonNumber":sn,"episodeNumbers":f.get("episodeNumbers"),"quality":qual,"size":size})
emit(f"{len(data)} episode file(s):\n"+"\n".join(lines), {"episodeFiles":out})
def cmd_search(client, args):
p = argparse.ArgumentParser(prog="sonarr-cli search")
p.add_argument("--series-id", type=int, required=True, help="Sonarr series ID")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Search series id={parsed.series_id}", {"dry_run":True, "name":"SeriesSearch", "seriesIds":[parsed.series_id]})
data = client.command("SeriesSearch", seriesIds=[parsed.series_id])
emit(f"Search triggered: command #{data.get('id')} — {data.get('status','?')}",
{"id":data.get("id"), "status":data.get("status"), "name":data.get("name")})
def cmd_root_folder(client, args):
if client.dry_run: return emit("[dry-run] List root folders", {"dry_run":True})
data = client.root_folders() or []
if not data: return emit("No root folders configured.", {"rootFolders":[]})
lines = [f" {r.get('path','?')} ({r.get('freeSpace',0)//1073741824}GB free)" for r in data]
emit(f"{len(data)} root folder(s):\n"+"\n".join(lines), {"rootFolders":data})
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)
sub.add_parser("queue")
sub.add_parser("history")
p_add = sub.add_parser("add", help="Add a series to library")
p_add.add_argument("--tvdb-id", type=int, required=True)
p_add.add_argument("--root", required=True)
p_add.add_argument("--quality-profile", type=int, default=4)
p_add.add_argument("--series-type", default="Standard", choices=["Standard","Daily","Anime"])
p_add.add_argument("--unmonitored", action="store_true")
p_add.add_argument("--search", action="store_true")
p_add.add_argument("--no-season-folder", action="store_true")
sub.add_parser("quality-profile", help="List or inspect quality profiles").add_argument("id", nargs="?", type=int)
p_ef = sub.add_parser("episode-file", help="List downloaded episode files")
p_ef.add_argument("--series-id", type=int, required=True)
sub.add_parser("search", help="Trigger automatic search").add_argument("--series-id", type=int, required=True)
sub.add_parser("root-folder", help="List configured root folders")
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,"queue":cmd_queue,"history":cmd_history,
"add":cmd_add,"quality-profile":cmd_quality_profile,"episode-file":cmd_episode_files,
"search":cmd_search,"root-folder":cmd_root_folder}
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()
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
# test-arr-cli.sh — Dry-run smoke tests for arr-cli
# No API keys or live servers needed. All tests use --dry-run.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1"; }
check() {
local desc="$1" cmd="$2" pattern="$3"
output=$(eval "$cmd" 2>&1) && echo "$output" | grep -q "$pattern" && ok "$desc" || fail "$desc (expected '$pattern' in output)"
}
echo "=== arr-cli smoke tests ==="
echo ""
echo "--- radarr-cli ---"
check "help shows add subcommand" \
"python3 $DIR/radarr-cli --help" \
" add"
check "help shows search subcommand" \
"python3 $DIR/radarr-cli --help" \
" search"
check "help shows quality-profile" \
"python3 $DIR/radarr-cli --help" \
"quality-profile"
check "dry-run status" \
"python3 $DIR/radarr-cli --dry-run status" \
"dry-run"
check "dry-run movies" \
"python3 $DIR/radarr-cli --dry-run movies" \
"dry-run"
check "dry-run lookup" \
"python3 $DIR/radarr-cli --dry-run lookup --term Dune" \
"dry-run"
check "dry-run add" \
"python3 $DIR/radarr-cli --dry-run add --tmdb-id 550 --root /movies" \
"dry-run.*550"
check "dry-run quality-profile (all)" \
"python3 $DIR/radarr-cli --dry-run quality-profile" \
"dry-run"
check "dry-run quality-profile (by id)" \
"python3 $DIR/radarr-cli --dry-run quality-profile 4" \
"dry-run"
check "dry-run search" \
"python3 $DIR/radarr-cli --dry-run search --movie-id 5" \
"dry-run.*movie id=5"
check "dry-run queue" \
"python3 $DIR/radarr-cli --dry-run queue" \
"dry-run"
check "dry-run history" \
"python3 $DIR/radarr-cli --dry-run history" \
"dry-run"
check "dry-run root-folder" \
"python3 $DIR/radarr-cli --dry-run root-folder" \
"dry-run"
check "dry-run calendar" \
"python3 $DIR/radarr-cli --dry-run calendar" \
"dry-run"
check "dry-run collections" \
"python3 $DIR/radarr-cli --dry-run collections" \
"dry-run"
echo ""
echo "--- sonarr-cli ---"
check "help shows add subcommand" \
"python3 $DIR/sonarr-cli --help" \
" add"
check "help shows quality-profile" \
"python3 $DIR/sonarr-cli --help" \
"quality-profile"
check "help shows episode-file" \
"python3 $DIR/sonarr-cli --help" \
"episode-file"
check "dry-run status" \
"python3 $DIR/sonarr-cli --dry-run status" \
"dry-run"
check "dry-run series" \
"python3 $DIR/sonarr-cli --dry-run series" \
"dry-run"
check "dry-run lookup" \
"python3 $DIR/sonarr-cli --dry-run lookup --term Severance" \
"dry-run"
check "dry-run add" \
"python3 $DIR/sonarr-cli --dry-run add --tvdb-id 77526 --root /tv --series-type Standard" \
"dry-run.*77526"
check "dry-run add with anime flags" \
"python3 $DIR/sonarr-cli --dry-run add --tvdb-id 12345 --root /tv --series-type Anime --no-season-folder" \
"dry-run"
check "dry-run quality-profile (all)" \
"python3 $DIR/sonarr-cli --dry-run quality-profile" \
"dry-run"
check "dry-run quality-profile (by id)" \
"python3 $DIR/sonarr-cli --dry-run quality-profile 4" \
"dry-run"
check "dry-run episode-file" \
"python3 $DIR/sonarr-cli --dry-run episode-file --series-id 1" \
"dry-run"
check "dry-run search" \
"python3 $DIR/sonarr-cli --dry-run search --series-id 5" \
"dry-run.*series id=5"
check "dry-run queue" \
"python3 $DIR/sonarr-cli --dry-run queue" \
"dry-run"
check "dry-run history" \
"python3 $DIR/sonarr-cli --dry-run history" \
"dry-run"
check "dry-run root-folder" \
"python3 $DIR/sonarr-cli --dry-run root-folder" \
"dry-run"
check "dry-run calendar" \
"python3 $DIR/sonarr-cli --dry-run calendar" \
"dry-run"
check "dry-run wanted" \
"python3 $DIR/sonarr-cli --dry-run wanted --limit 5" \
"dry-run"
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
exit $FAIL
-35
View File
@@ -1,35 +0,0 @@
# Lidarr Music Library Management
Manage your Lidarr music library from the terminal — search and browse artists and albums, add new artists, check calendars, view queue and download history.
## Why Install This Skill
When your agent loads this skill, it can **manage your entire music library** through Lidarr. That means:
- **List artists and albums** — browse your curated music collection
- **Add new artists** — search and add with MusicBrainz IDs
- **Track upcoming albums** — calendar for scheduled releases
- **Monitor downloads** — queue, history, wanted/ missing albums
- **Check profiles** — quality and metadata profiles
## What You Get
| Directory | Purpose |
|-----------|---------|
| `SKILL.md` | Complete command reference with trigger table |
| `scripts/lidarr-cli` | CLI tool for Lidarr API |
## Quick Start
```bash
export ARR_SERVER_LIDARR="http://localhost:8686"
export ARR_KEY_LIDARR="your-lidarr-api-key"
```
## Triggers
Load this when working with Lidarr, music library management, adding artists, or checking upcoming album releases.
## Requirements
Python 3.8+ with `requests` library. API key from Lidarr → Settings → General.
-96
View File
@@ -1,96 +0,0 @@
---
name: lidarr-cli
description: Manage your Lidarr music library from the terminal. Search and browse
artists and albums, add new artists, check calendars, view queue and download history,
inspect quality and metadata profiles, and trigger searches. Use when the user mentions
Lidarr, music management, adding an artist, searching for music, checking the music
library, upcoming album releases, or download queue.
license: MIT
compatibility: Python 3.8+ with `requests` library. Requires ARR_SERVER_LIDARR and
ARR_KEY_LIDARR (from Lidarr → Settings → General → API Key).
metadata:
tags: lidarr, music, arr-stack, media-server, music-automation, api-client
sources: https://lidarr.audio/
---
# lidarr-cli — Lidarr Music Library Management
CLI for Lidarr music management. Part of the *arr family — shares the same
API patterns as radarr-cli and sonarr-cli.
## Quick Setup
```bash
export ARR_SERVER_LIDARR="http://localhost:8686"
export ARR_KEY_LIDARR="your-lidarr-api-key"
```
`--help` and `--dry-run` work without credentials.
## When to Use Which
| User says... | Load... |
|---|---|
| "list my artists", "what's in Lidarr", "show my music library" | `lidarr-cli artists [--limit N]` |
| "add this artist", "find an artist" | `lidarr-cli lookup --term "name"` then `lidarr-cli add --mb-id UUID --root /music` |
| "what albums does this artist have" | `lidarr-cli albums --artist-id N` |
| "show me the tracks" | `lidarr-cli tracks --album-id N` |
| "what's coming up", "upcoming albums" | `lidarr-cli calendar` |
| "what's in the queue", "download progress" | `lidarr-cli queue` |
| "show me history", "what was downloaded" | `lidarr-cli history` |
| "what's missing", "wanted albums" | `lidarr-cli wanted` |
| "check quality profiles" | `lidarr-cli quality-profile` |
| "check metadata profiles" | `lidarr-cli metadata-profile` |
| "storage space", "disk usage" | `lidarr-cli disk-space` |
| "search for an artist" (already added) | `lidarr-cli search --artist-id N` |
## Quick Reference
**Global flags:** `--json`, `--dry-run`, `--force`, `--quiet`, `--verbose`
```bash
# System info
lidarr-cli status
# Discover and add an artist (use MusicBrainz ID, not title)
lidarr-cli lookup --term "Radiohead"
lidarr-cli add --mb-id a74b1b7f-71a5-4011-9441-d0b5e4122711 --root /music --quality-profile 4 --search
# Browse content
lidarr-cli albums --artist-id 1
lidarr-cli tracks --album-id 1
lidarr-cli track-files --album-id 1
# Preview before adding
lidarr-cli --dry-run add --mb-id a74b1b7f-71a5-4011-9441-d0b5e4122711 --root /music
# Queue, history, wanted
lidarr-cli queue --limit 10
lidarr-cli wanted --limit 10
lidarr-cli history --limit 5
# Profiles and storage
lidarr-cli quality-profile # list all
lidarr-cli quality-profile 4 # inspect specific
lidarr-cli metadata-profile # list metadata profiles
lidarr-cli disk-space # storage usage
lidarr-cli health # health warnings
```
**Artist lookup by MusicBrainz ID (MBID):** Lidarr uses MusicBrainz IDs (UUID format,
e.g. `a74b1b7f-71a5-4011-9441-d0b5e4122711`) instead of TMDb/TVDb integer IDs.
**Default quality profile:** 4. **Default metadata profile:** 1.
## Reference Files
| File | Contents |
|------|----------|
| `references/lidarr-commands.md` | Full command reference with all flags and examples |
| `references/troubleshooting.md` | Pitfalls, FAQs, and when NOT to use |
## When NOT to Use
- **Music playback** — use Jellyfin for listening. This CLI only manages the library.
- **First-time Lidarr setup** — assumes Lidarr is already installed and configured.
- **Bulk imports** — use Lidarr's built-in import features for large migrations.
-178
View File
@@ -1,178 +0,0 @@
---
name: lidarr-cli/commands
description: "Full lidarr-cli command reference — all subcommands, flags, and examples."
---
# lidarr-cli Commands — Full Reference
## System
```bash
lidarr-cli status # version, OS, database
lidarr-cli status --json # machine-readable
```
## Artists
```bash
lidarr-cli artists # all artists
lidarr-cli artists --limit 10 # top 10
lidarr-cli artists --json # machine-readable (includes foreignArtistId)
```
**Icons:** ✅ = has albums, 👁️ = monitored but empty, 🚫 = unmonitored.
## Lookup (search artists to add)
```bash
lidarr-cli lookup --term "Radiohead" # search by name
lidarr-cli lookup --term "Radiohead" --json # includes MusicBrainz ID
```
Results include `foreignArtistId` (MusicBrainz UUID) — this is the ID to use with `add`.
## Lookup Album
```bash
lidarr-cli lookup-album --term "OK Computer"
lidarr-cli lookup-album --term "OK Computer" --json
```
## Add
```bash
# Basic: add monitored with auto-search
lidarr-cli add --mb-id a74b1b7f-71a5-4011-9441-d0b5e4122711 --root /music --quality-profile 4 --search
# Add with specific metadata profile
lidarr-cli add --mb-id UUID --root /music --quality-profile 4 --metadata-profile 2 --search
# Add unmonitored
lidarr-cli add --mb-id UUID --root /music --unmonitored
# Preview
lidarr-cli --dry-run add --mb-id UUID --root /music
```
| Flag | Default | Description |
|------|---------|-------------|
| `--mb-id` | required | MusicBrainz ID (UUID format) |
| `--root` | required | Root folder path (discover with `root-folder`) |
| `--quality-profile` | 4 | Quality profile ID |
| `--metadata-profile` | 1 | Metadata profile ID |
| `--unmonitored` | off | Add without monitoring (default: monitored) |
| `--search` | off | Search for albums after adding |
## Albums
```bash
lidarr-cli albums --artist-id 1 # all albums for an artist
lidarr-cli albums --artist-id 1 --limit 5 # limit results
lidarr-cli albums --artist-id 1 --json # machine-readable
```
Get the Lidarr artist ID from `lidarr-cli artists --json`.
## Tracks
```bash
lidarr-cli tracks --album-id 1 # all tracks for an album
lidarr-cli tracks --album-id 1 --limit 10 # limit results
lidarr-cli tracks --album-id 1 --json # machine-readable
```
## Track Files
```bash
lidarr-cli track-files --album-id 1 # downloaded files with quality and size
lidarr-cli track-files --album-id 1 --json
```
## Quality Profiles
```bash
lidarr-cli quality-profile # list all profiles
lidarr-cli quality-profile 4 # inspect specific (cutoff, tier checkmarks)
lidarr-cli --json quality-profile # machine-readable
```
## Metadata Profiles
```bash
lidarr-cli metadata-profile # list all metadata profiles
lidarr-cli metadata-profile --json
```
Metadata profiles control how Lidarr handles album metadata (tags, artwork, etc.).
Profile 1 is typically "Standard" — the default.
## Calendar
```bash
lidarr-cli calendar # upcoming album releases
lidarr-cli calendar --json
```
## Queue
```bash
lidarr-cli queue # current download queue
lidarr-cli queue --limit 50
lidarr-cli queue --json
```
## History
```bash
lidarr-cli history # recent activity
lidarr-cli history --limit 50
lidarr-cli history --event-type grabbed
lidarr-cli history --json
```
## Wanted (Missing Albums)
```bash
lidarr-cli wanted # missing albums
lidarr-cli wanted --limit 50
lidarr-cli wanted --json
```
## Search (trigger on existing artists)
```bash
lidarr-cli search --artist-id 5 # trigger automatic search (Lidarr artist ID)
```
Returns a command object immediately — search runs async in the background.
## Root Folders
```bash
lidarr-cli root-folder # list all root folders with free space
lidarr-cli root-folder --json
```
## Disk Space
```bash
lidarr-cli disk-space # storage usage per path
lidarr-cli disk-space --json
```
## Health
```bash
lidarr-cli health # health check warnings
lidarr-cli health --json
```
## JSON Output
All commands accept `--json`. Pipe to `jq` for scripting:
```bash
lidarr-cli --json artists | jq '.artists[] | {artistName, foreignArtistId, monitored}'
lidarr-cli --json wanted | jq '.wanted[] | {artist, title, releaseDate}'
lidarr-cli --json queue | jq '.queue[] | {title, status, progress}'
```
-68
View File
@@ -1,68 +0,0 @@
---
name: lidarr-cli/troubleshooting
description: "Common pitfalls, FAQs, and when NOT to use lidarr-cli."
---
# Troubleshooting & FAQs
## Common Pitfalls
### Artist lookup uses MusicBrainz ID, not a numeric ID
Lidarr uses MusicBrainz IDs (UUID format) as the canonical identifier. Always use
`--mb-id UUID` when adding an artist — not a numeric ID.
```bash
# ✅ Correct: MusicBrainz UUID
lidarr-cli add --mb-id a74b1b7f-71a5-4011-9441-d0b5e4122711 --root /music
# ❌ Wrong: numeric IDs won't work for artists
```
### Adding requires a root folder path
`lidarr-cli add --root /music` — the root folder must be an exact path known to Lidarr:
```bash
lidarr-cli root-folder
```
### Music is deeper than movies: artist → album → track
Lidarr has a three-level hierarchy:
- `lookup` → search artists
- `albums --artist-id N` → browse albums
- `tracks --album-id N` → browse tracks within an album
- `track-files --album-id N` → see downloaded files
### Metadata profiles are Lidarr-specific
Lidarr uses metadata profiles (album artwork, tag handling) that don't exist in
Radarr/Sonarr. Default is profile 1. List them with:
```bash
lidarr-cli metadata-profile
```
## When Not to Use
- **Music playback** — use Jellyfin for listening content
- **First-time Lidarr setup** — install and configure Lidarr first (lidarr.audio)
- **Bulk library migration** — use Lidarr's built-in import tools
## FAQ
**Q: Where do I find an artist's MusicBrainz ID?**
A: Use `lidarr-cli lookup --term "artist name" --json` — results include
`foreignArtistId`. Or search musicbrainz.org directly.
**Q: How do I find the Lidarr artist ID for a search?**
A: Run `lidarr-cli artists --json | jq '.artists[] | {artistName, id}'`
**Q: What's the difference between quality profile and metadata profile?**
A: Quality profile controls audio format (FLAC, MP3, etc.). Metadata profile
controls how album metadata (tags, artwork) is managed. Both are set at add time.
**Q: Can I trigger a search for an existing artist's albums?**
A: Yes. Find the Lidarr artist ID with `lidarr-cli artists --json`, then
`lidarr-cli search --artist-id N`. This searches for all missing albums.
-392
View File
@@ -1,392 +0,0 @@
#!/usr/bin/env python3
"""lidarr-cli — Lidarr music 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_LIDARR", "http://localhost:8686")
ENV_KEY = os.getenv("ARR_KEY_LIDARR", "")
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 LidarrClient:
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/v1{path}"
if self.dry_run: return {"dry_run":True, "url":url, "params":params}
if not self.key: die("ARR_KEY_LIDARR not set. Get one from Lidarr → 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_LIDARR.")
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/v1{path}"
if self.dry_run: return {"dry_run":True, "url":url, "json":data}
if not self.key: die("ARR_KEY_LIDARR 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 artists(self): return self._get("/artist")
def artist(self, aid): return self._get(f"/artist/{aid}")
def lookup_artist(self, term): return self._get("/artist/lookup", {"term": term})
def lookup_album(self, term): return self._get("/album/lookup", {"term": term})
def albums(self, artist_id): return self._get("/album", {"artistId": artist_id})
def album(self, alb_id): return self._get(f"/album/{alb_id}")
def tracks(self, album_id): return self._get("/track", {"albumId": album_id})
def track_files(self, album_id): return self._get("/trackfile", {"albumId": album_id})
def quality_profiles(self): return self._get("/qualityprofile")
def quality_profile(self, pid): return self._get(f"/qualityprofile/{pid}")
def metadata_profiles(self): return self._get("/metadataprofile")
def root_folders(self): return self._get("/rootfolder")
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 disk_space(self): return self._get("/diskSpace")
def health(self): return self._get("/health")
def add_artist(self, mb_id, quality_profile=4, metadata_profile=1, root_folder_path="",
monitored=True, search=True):
return self._post("/artist", {"foreignArtistId":mb_id, "qualityProfileId":quality_profile,
"metadataProfileId":metadata_profile,
"rootFolderPath":root_folder_path, "monitored":monitored,
"addOptions":{"searchForNewAlbum":search}})
def command(self, name, **kwargs):
return self._post("/command", {"name":name, **kwargs})
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"🎵 Lidarr v{d.get('version','?')} OS: {d.get('osName','?')} DB: {d.get('databaseVersion','?')}",
{"version":d.get("version"),"osName":d.get("osName")})
def cmd_artists(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli artists")
p.add_argument("--limit", type=int, default=50)
p.add_argument("--status", help="Filter by status (continue, ended, etc.)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit("[dry-run] List artists", {"dry_run":True})
data = client.artists() or []
if parsed.status: data = [a for a in data if a.get("status") == parsed.status]
data = data[:parsed.limit]
if not data: return emit("No artists found.", {"artists":[]})
lines, out = [], []
for a in data:
title = a.get("artistName","?"); mon = a.get("monitored",False)
alb_count = a.get("statistics",{}).get("albumCount",0)
icon = "✅" if alb_count > 0 else ("👁️" if mon else "🚫")
lines.append(f" {icon} {title:45} {alb_count} albums")
out.append({"artistName":title,"monitored":mon,"albumCount":alb_count,"id":a.get("id"),"foreignArtistId":a.get("foreignArtistId")})
emit(f"{len(data)} artist(s):\n"+"\n".join(lines), {"artists":out})
def cmd_lookup(client, args):
p = argparse.ArgumentParser(prog="lidarr-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_artist(parsed.term) or []
if not data: return emit("No results.", {"results":[]})
lines, out = [], []
for a in data[:20]:
name = a.get("artistName","?"); mbid = a.get("foreignArtistId","?")
lines.append(f" {name:45} mb={mbid}")
out.append({"artistName":name,"foreignArtistId":mbid,"overview":(a.get("overview") or "")[:120]})
emit(f"{len(data)} result(s):\n"+"\n".join(lines), {"results":out})
def cmd_lookup_album(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli lookup-album")
p.add_argument("--term", "-t", required=True)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] Lookup album: {parsed.term}", {"dry_run":True})
data = client.lookup_album(parsed.term) or []
if not data: return emit("No results.", {"results":[]})
lines, out = [], []
for alb in data[:20]:
title = alb.get("title","?"); artist = alb.get("artist",{}).get("artistName","?")
date = (alb.get("releaseDate") or "")[:10]
lines.append(f" {title:45} {artist:25} {date}")
out.append({"title":title,"artist":artist,"releaseDate":date,"id":alb.get("id")})
emit(f"{len(data)} result(s):\n"+"\n".join(lines), {"results":out})
def cmd_add(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli add")
p.add_argument("--mb-id", required=True, help="MusicBrainz ID (UUID)")
p.add_argument("--root", required=True, help="Root folder path (e.g. /music)")
p.add_argument("--quality-profile", type=int, default=4, help="Quality profile ID (default: 4)")
p.add_argument("--metadata-profile", type=int, default=1, help="Metadata profile ID (default: 1)")
p.add_argument("--unmonitored", action="store_true", help="Add unmonitored")
p.add_argument("--search", action="store_true", help="Search for albums after adding")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Add artist mb={parsed.mb_id} profile={parsed.quality_profile}",
{"dry_run":True, "foreignArtistId":parsed.mb_id, "qualityProfileId":parsed.quality_profile,
"metadataProfileId":parsed.metadata_profile, "rootFolderPath":parsed.root,
"monitored":not parsed.unmonitored})
data = client.add_artist(parsed.mb_id, quality_profile=parsed.quality_profile,
metadata_profile=parsed.metadata_profile, root_folder_path=parsed.root,
monitored=not parsed.unmonitored, search=parsed.search)
emit(f"Added: #{data.get('id')} {data.get('artistName')}",
{"id":data.get("id"), "artistName":data.get("artistName")})
def cmd_albums(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli albums")
p.add_argument("--artist-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(f"[dry-run] List albums for artist {parsed.artist_id}", {"dry_run":True})
data = client.albums(parsed.artist_id) or []
data = data[:parsed.limit]
if not data: return emit("No albums found.", {"albums":[]})
lines, out = [], []
for alb in data:
title = alb.get("title","?"); date = (alb.get("releaseDate") or "")[:10]
mon = alb.get("monitored",False); tracks = alb.get("statistics",{}).get("trackCount",0)
icon = "✅" if alb.get("statistics",{}).get("trackFileCount",0) > 0 else ("👁️" if mon else "🚫")
lines.append(f" {icon} {title:45} {date} {tracks} tracks")
out.append({"title":title,"releaseDate":date,"monitored":mon,"trackCount":tracks,"id":alb.get("id")})
emit(f"{len(data)} album(s):\n"+"\n".join(lines), {"albums":out})
def cmd_tracks(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli tracks")
p.add_argument("--album-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(f"[dry-run] List tracks for album {parsed.album_id}", {"dry_run":True})
data = client.tracks(parsed.album_id) or []
data = data[:parsed.limit]
if not data: return emit("No tracks found.", {"tracks":[]})
lines, out = [], []
for t in data:
tn = t.get("trackNumber"); title = t.get("title","?")
had = t.get("hasFile",False); mon = t.get("monitored",False)
dur = t.get("duration",0)//1000
tn_str = f"{tn:3d}" if isinstance(tn, int) else " ?"
mins = f"{dur//60}:{dur%60:02d}" if dur else "?"
icon = "✅" if had else ("👁️" if mon else "🚫")
lines.append(f" {icon} {tn_str} {title:45} {mins}")
out.append({"trackNumber":tn,"title":title,"hasFile":had,"monitored":mon,"duration":dur})
emit(f"{len(data)} track(s):\n"+"\n".join(lines), {"tracks":out})
def cmd_track_files(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli track-files")
p.add_argument("--album-id", type=int, required=True)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] List track files for album {parsed.album_id}", {"dry_run":True})
data = client.track_files(parsed.album_id) or []
if not data: return emit("No track files found.", {"trackFiles":[]})
lines, out = [], []
for f in data:
qual = f.get("quality",{}).get("quality",{}).get("name","?")
size = (f.get("size") or 0)//1048576
lines.append(f" {qual:25} {size}MB")
out.append({"quality":qual,"size":size,"id":f.get("id")})
emit(f"{len(data)} track file(s):\n"+"\n".join(lines), {"trackFiles":out})
def cmd_quality_profile(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli quality-profile")
p.add_argument("id", nargs="?", type=int, help="Profile ID (omit to list all)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] Quality profile: {parsed.id or 'all'}", {"dry_run":True})
if parsed.id:
qp = client.quality_profile(parsed.id) or {}
items = qp.get("items",[])
lines = [f" {qp.get('name','?')} (id={qp.get('id','?')}) cutoff={qp.get('cutoff',{}).get('name','?')}"]
for item in items:
name = item.get("quality",{}).get("name","?")
allowed = "✓" if item.get("allowed") else "✗"
lines.append(f" {allowed} {name}")
emit("\n".join(lines), qp)
else:
profiles = client.quality_profiles() or []
lines = [f" {p.get('id')}: {p.get('name','?')}" for p in profiles]
emit(f"{len(profiles)} quality profile(s):\n"+"\n".join(lines), {"profiles":profiles})
def cmd_metadata_profile(client, args):
if client.dry_run: return emit("[dry-run] List metadata profiles", {"dry_run":True})
profiles = client.metadata_profiles() or []
if not profiles: return emit("No metadata profiles.", {"metadataProfiles":[]})
lines = [f" {p.get('id')}: {p.get('name','?')}" for p in profiles]
emit(f"{len(profiles)} metadata profile(s):\n"+"\n".join(lines), {"metadataProfiles":profiles})
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 releases.", {"albums":[]})
lines, out = [], []
for alb in data[:30]:
title = alb.get("title","?"); artist = alb.get("artist",{}).get("artistName","?")
date = (alb.get("releaseDate") or "")[:10]
lines.append(f" {date} {artist:30} — {title:45}")
out.append({"title":title,"artist":artist,"releaseDate":date})
emit(f"Upcoming:\n"+"\n".join(lines), {"albums":out})
def cmd_queue(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli queue")
p.add_argument("--limit", type=int, default=20)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit("[dry-run] List queue", {"dry_run":True})
data = client.queue(limit=parsed.limit) or []
if not data: return emit("Queue is empty.", {"queue":[]})
lines, out = [], []
for item in data:
title = item.get("title","?"); status = item.get("status","?")
pct = item.get("sizeleft",0) and item.get("size",1) and f"{100-item.get('sizeleft',0)*100//item.get('size',1):2d}%" or "?"
lines.append(f" {title:45} [{status:10}] {pct}")
out.append({"title":title,"status":status,"progress":pct})
emit(f"{len(data)} in queue:\n"+"\n".join(lines), {"queue":out})
def cmd_history(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli history")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--event-type", help="Filter by event type (grabbed, importFailed, etc.)")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit("[dry-run] List history", {"dry_run":True})
data = client.history(limit=parsed.limit) or {}
records = data.get("records",[]) if isinstance(data,dict) else data[:parsed.limit]
if not records: return emit("No history.", {"history":[]})
lines, out = [], []
for e in records[:parsed.limit]:
title = e.get("artist",{}).get("artistName","?") if isinstance(e.get("artist"),dict) else e.get("artistName","?")
evt = e.get("eventType","?"); date = (e.get("date") or "")[:10]
lines.append(f" {date} {title:45} {evt}")
out.append({"date":date,"title":title,"eventType":evt})
emit(f"History:\n"+"\n".join(lines), {"history":out})
def cmd_wanted(client, args):
p = argparse.ArgumentParser(prog="lidarr-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 albums.", {"wanted":[]})
lines, out = [], []
for alb in records:
title = alb.get("title","?"); artist = alb.get("artist",{}).get("artistName","?")
date = (alb.get("releaseDate") or "")[:10]
lines.append(f" {artist:30} — {title:45} {date}")
out.append({"artist":artist,"title":title,"releaseDate":date})
total = data.get("totalRecords", len(records))
emit(f"{total} missing album(s):\n"+"\n".join(lines), {"total":total,"wanted":out})
def cmd_search(client, args):
p = argparse.ArgumentParser(prog="lidarr-cli search")
p.add_argument("--artist-id", type=int, required=True, help="Lidarr artist ID")
parsed, _ = p.parse_known_args(args)
if client.dry_run:
return emit(f"[dry-run] Search artist id={parsed.artist_id}", {"dry_run":True, "name":"ArtistSearch", "artistIds":[parsed.artist_id]})
data = client.command("ArtistSearch", artistIds=[parsed.artist_id])
emit(f"Search triggered: command #{data.get('id')} — {data.get('status','?')}",
{"id":data.get("id"), "status":data.get("status"), "name":data.get("name")})
def cmd_root_folder(client, args):
if client.dry_run: return emit("[dry-run] List root folders", {"dry_run":True})
data = client.root_folders() or []
if not data: return emit("No root folders configured.", {"rootFolders":[]})
lines = [f" {r.get('path','?')} ({r.get('freeSpace',0)//1073741824}GB free)" for r in data]
emit(f"{len(data)} root folder(s):\n"+"\n".join(lines), {"rootFolders":data})
def cmd_disk_space(client, args):
if client.dry_run: return emit("[dry-run] List disk space", {"dry_run":True})
data = client.disk_space() or []
if not data: return emit("No disk space info.", {"diskSpace":[]})
lines = []
for d in data:
path = d.get("path","?"); free = d.get("freeSpace",0)//1073741824
total = d.get("totalSpace",0)//1073741824
lines.append(f" {path:30} {free}/{total} GB free")
emit(f"Disk space:\n"+"\n".join(lines), {"diskSpace":data})
def cmd_health(client, args):
if client.dry_run: return emit("[dry-run] Get health", {"dry_run":True})
data = client.health() or []
if not data: return emit("All healthy.", {"health":[]})
lines = [f" {h.get('type','?'):20} — {h.get('message','?')}" for h in data]
emit(f"{len(data)} warning(s):\n"+"\n".join(lines), {"health":data})
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="lidarr-cli", description="Lidarr music management.",
epilog="Set ARR_SERVER_LIDARR and ARR_KEY_LIDARR.")
sub = parser.add_subparsers(dest="command")
sub.add_parser("status")
sub.add_parser("artists", help="List artists").add_argument("--limit", type=int, default=50)
sub.add_parser("lookup", help="Search for artists").add_argument("--term","-t",required=True)
sub.add_parser("lookup-album", help="Search for albums").add_argument("--term","-t",required=True)
p_add = sub.add_parser("add", help="Add an artist to library")
p_add.add_argument("--mb-id", required=True)
p_add.add_argument("--root", required=True)
p_add.add_argument("--quality-profile", type=int, default=4)
p_add.add_argument("--metadata-profile", type=int, default=1)
p_add.add_argument("--unmonitored", action="store_true")
p_add.add_argument("--search", action="store_true")
sub.add_parser("albums", help="List albums").add_argument("--artist-id", type=int, required=True)
sub.add_parser("tracks", help="List tracks").add_argument("--album-id", type=int, required=True)
sub.add_parser("track-files", help="List track files").add_argument("--album-id", type=int, required=True)
sub.add_parser("quality-profile", help="List or inspect quality profiles").add_argument("id", nargs="?", type=int)
sub.add_parser("metadata-profile", help="List metadata profiles")
sub.add_parser("root-folder", help="List root folders")
sub.add_parser("calendar")
sub.add_parser("queue")
sub.add_parser("history")
sub.add_parser("wanted")
sub.add_parser("search", help="Trigger automatic search").add_argument("--artist-id", type=int, required=True)
sub.add_parser("disk-space", help="Show disk usage")
sub.add_parser("health", help="Show health warnings")
args = parser.parse_args(filtered_argv[1:])
if not args.command: parser.print_help(); sys.exit(1)
client = LidarrClient(dry_run=GLOBAL_FLAGS.get("dry_run",False))
handlers = {
"status":cmd_status, "artists":cmd_artists, "lookup":cmd_lookup,
"lookup-album":cmd_lookup_album, "add":cmd_add, "albums":cmd_albums,
"tracks":cmd_tracks, "track-files":cmd_track_files,
"quality-profile":cmd_quality_profile, "metadata-profile":cmd_metadata_profile,
"root-folder":cmd_root_folder, "calendar":cmd_calendar,
"queue":cmd_queue, "history":cmd_history, "wanted":cmd_wanted,
"search":cmd_search, "disk-space":cmd_disk_space, "health":cmd_health,
}
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()
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# test-lidarr-cli.sh — Dry-run smoke tests for lidarr-cli
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1"; }
check() {
local desc="$1" cmd="$2" pattern="$3"
output=$(eval "$cmd" 2>&1) && echo "$output" | grep -q "$pattern" && ok "$desc" || fail "$desc (expected '$pattern')"
}
echo "=== lidarr-cli smoke tests ==="
echo ""
echo "--- help ---"
check "help shows status" "python3 $DIR/lidarr-cli --help" "status"
check "help shows artists" "python3 $DIR/lidarr-cli --help" "artists"
check "help shows lookup" "python3 $DIR/lidarr-cli --help" "lookup"
check "help shows lookup-album" "python3 $DIR/lidarr-cli --help" "lookup-album"
check "help shows add" "python3 $DIR/lidarr-cli --help" "add"
check "help shows albums" "python3 $DIR/lidarr-cli --help" "albums"
check "help shows tracks" "python3 $DIR/lidarr-cli --help" "tracks"
check "help shows track-files" "python3 $DIR/lidarr-cli --help" "track-files"
check "help shows quality-profile" "python3 $DIR/lidarr-cli --help" "quality-profile"
check "help shows metadata-profile" "python3 $DIR/lidarr-cli --help" "metadata-profile"
check "help shows root-folder" "python3 $DIR/lidarr-cli --help" "root-folder"
check "help shows calendar" "python3 $DIR/lidarr-cli --help" "calendar"
check "help shows queue" "python3 $DIR/lidarr-cli --help" "queue"
check "help shows history" "python3 $DIR/lidarr-cli --help" "history"
check "help shows wanted" "python3 $DIR/lidarr-cli --help" "wanted"
check "help shows search" "python3 $DIR/lidarr-cli --help" "search"
check "help shows disk-space" "python3 $DIR/lidarr-cli --help" "disk-space"
check "help shows health" "python3 $DIR/lidarr-cli --help" "health"
echo ""
echo "--- dry-run commands ---"
check "dry-run status" "python3 $DIR/lidarr-cli --dry-run status" "dry-run"
check "dry-run artists" "python3 $DIR/lidarr-cli --dry-run artists" "dry-run"
check "dry-run lookup" "python3 $DIR/lidarr-cli --dry-run lookup --term Radiohead" "dry-run"
check "dry-run lookup-album" "python3 $DIR/lidarr-cli --dry-run lookup-album --term OK" "dry-run"
check "dry-run add" "python3 $DIR/lidarr-cli --dry-run add --mb-id a74b1b7f --root /music" "dry-run.*a74b1b7f"
check "dry-run albums" "python3 $DIR/lidarr-cli --dry-run albums --artist-id 1" "dry-run"
check "dry-run tracks" "python3 $DIR/lidarr-cli --dry-run tracks --album-id 1" "dry-run"
check "dry-run track-files" "python3 $DIR/lidarr-cli --dry-run track-files --album-id 1" "dry-run"
check "dry-run quality-profile (all)" "python3 $DIR/lidarr-cli --dry-run quality-profile" "dry-run"
check "dry-run quality-profile (by id)" "python3 $DIR/lidarr-cli --dry-run quality-profile 4" "dry-run"
check "dry-run metadata-profile" "python3 $DIR/lidarr-cli --dry-run metadata-profile" "dry-run"
check "dry-run root-folder" "python3 $DIR/lidarr-cli --dry-run root-folder" "dry-run"
check "dry-run calendar" "python3 $DIR/lidarr-cli --dry-run calendar" "dry-run"
check "dry-run queue" "python3 $DIR/lidarr-cli --dry-run queue" "dry-run"
check "dry-run history" "python3 $DIR/lidarr-cli --dry-run history" "dry-run"
check "dry-run wanted" "python3 $DIR/lidarr-cli --dry-run wanted" "dry-run"
check "dry-run search" "python3 $DIR/lidarr-cli --dry-run search --artist-id 5" "dry-run.*artist id=5"
check "dry-run disk-space" "python3 $DIR/lidarr-cli --dry-run disk-space" "dry-run"
check "dry-run health" "python3 $DIR/lidarr-cli --dry-run health" "dry-run"
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
exit $FAIL
-36
View File
@@ -1,36 +0,0 @@
# Prowlarr — Indexer Hub Management
Manage your Prowlarr indexer hub from the terminal — list and inspect indexers, view query/grab statistics, check health, manage connected *arr applications, and test indexer connectivity.
## Why Install This Skill
When your agent loads this skill, it can **manage your entire indexer infrastructure**. That means:
- **List and inspect indexers** — see what indexers are configured
- **Monitor performance** — query and grab statistics per indexer
- **Test connectivity** — verify indexers are responding
- **Check health** — overall system health and per-indexer status
- **View connected apps** — which *arr applications are linked
- **Browse search history** — see what's been searched and found
## What You Get
| Directory | Purpose |
|-----------|---------|
| `SKILL.md` | Complete command reference with trigger table |
| `scripts/prowlarr-cli` | CLI tool for Prowlarr API |
## Quick Start
```bash
export ARR_SERVER_PROWLARR="http://localhost:9696"
export ARR_KEY_PROWLARR="your-prowlarr-api-key"
```
## Triggers
Load this when working with Prowlarr, indexers, or the *arr stack's indexer hub.
## Requirements
Python 3.8+ with `requests` library. API key from Prowlarr → Settings → General.
-85
View File
@@ -1,85 +0,0 @@
---
name: prowlarr-cli
description: Manage your Prowlarr indexer hub from the terminal. List and inspect
indexers, view query/grab statistics, check health, manage connected *arr applications
and download clients, browse search history, and test indexer connectivity. Use
when the user mentions Prowlarr, indexers, indexer management, testing indexers,
search history, or the indexer hub.
license: MIT
compatibility: Python 3.8+ with `requests` library. Requires ARR_SERVER_PROWLARR and
ARR_KEY_PROWLARR (from Prowlarr → Settings → General → API Key).
metadata:
tags: prowlarr, indexer, arr-stack, media-server, api-client
sources: https://prowlarr.com/
---
# prowlarr-cli — Prowlarr Indexer Management
CLI for Prowlarr, the indexer manager for the *arr stack. Unlike radarr-cli
and sonarr-cli, this manages indexer infrastructure rather than media.
## Quick Setup
```bash
export ARR_SERVER_PROWLARR="http://localhost:9696"
export ARR_KEY_PROWLARR="your-prowlarr-api-key"
```
`--help` and `--dry-run` work without credentials.
## When to Use Which
| User says... | Load... |
|---|---|
| "list my indexers", "what indexers do I have" | `prowlarr-cli indexers` |
| "show me indexer 5", "details on this indexer" | `prowlarr-cli indexer 5` |
| "how are my indexers doing", "query stats" | `prowlarr-cli indexer-stats` |
| "are any indexers down" | `prowlarr-cli indexer-status` |
| "what apps are connected" | `prowlarr-cli applications` |
| "what download clients are configured" | `prowlarr-cli download-clients` |
| "show me search history" | `prowlarr-cli history` |
| "test all my indexers" | `prowlarr-cli test-all` |
| "prowlarr health" | `prowlarr-cli health` |
| "list tags" | `prowlarr-cli tags` |
## Quick Reference
**Global flags:** `--json`, `--dry-run`, `--force`, `--quiet`, `--verbose`
```bash
# System info
prowlarr-cli status
# Indexer management
prowlarr-cli indexers # all indexers
prowlarr-cli indexers --limit 10 # top 10
prowlarr-cli indexer 5 # single indexer details
prowlarr-cli indexer-stats # query/grab stats
prowlarr-cli indexer-status # health per indexer
# Infrastructure
prowlarr-cli applications # connected *arr apps
prowlarr-cli download-clients # configured download clients
prowlarr-cli tags # list tags
prowlarr-cli health # health warnings
# Search and testing
prowlarr-cli history --limit 20 # search history
prowlarr-cli test-all # test all indexers
# JSON output for scripting
prowlarr-cli --json indexers | jq '.indexers[] | {name, protocol, enable, status}'
prowlarr-cli --json health | jq '.[].message'
```
## Reference Files
| File | Contents |
|------|----------|
| `references/prowlarr-commands.md` | Full command reference |
| `references/troubleshooting.md` | Pitfalls and FAQs |
## When NOT to Use
- **Media search** — Prowlarr indexes content but doesn't download it. Use Radarr/Sonarr/Lidarr.
- **First-time Prowlarr setup** — assumes Prowlarr is already installed and configured.
@@ -1,111 +0,0 @@
---
name: prowlarr-cli/commands
description: "Full prowlarr-cli command reference — all subcommands, flags, and examples."
---
# prowlarr-cli Commands — Full Reference
## System
```bash
prowlarr-cli status # version, OS, database
prowlarr-cli status --json # machine-readable
```
## Indexers
```bash
prowlarr-cli indexers # all indexers
prowlarr-cli indexers --limit 10 # top 10
prowlarr-cli indexers --json # machine-readable
```
**Icons:** ✅ = enabled, 🚫 = disabled. Shows protocol (USENET/TORRENT), priority, and status.
## Indexer Details
```bash
prowlarr-cli indexer 5 # full details for indexer ID 5
prowlarr-cli --json indexer 5 # machine-readable
```
Shows implementation name, protocol, enabled state, priority, and status.
## Indexer Statistics
```bash
prowlarr-cli indexer-stats # query/grab counts per indexer
prowlarr-cli --json indexer-stats # machine-readable
```
Shows total queries, grabs, and average response time per indexer.
## Indexer Status
```bash
prowlarr-cli indexer-status # health/disabled status per indexer
prowlarr-cli --json indexer-status
```
Shows which indexers are disabled and their disabled-until information.
## Applications
```bash
prowlarr-cli applications # connected *arr applications
prowlarr-cli applications --json
```
Lists Radarr, Sonarr, Lidarr instances connected to Prowlarr with sync level.
## Download Clients
```bash
prowlarr-cli download-clients # configured download clients
prowlarr-cli download-clients --json
```
Shows all download clients configured in Prowlarr with protocol and enabled status.
## History
```bash
prowlarr-cli history # recent search history
prowlarr-cli history --limit 50 # more results
prowlarr-cli history --json # machine-readable
```
Shows what was searched, which indexer handled it, and the event type.
## Health
```bash
prowlarr-cli health # health check warnings
prowlarr-cli health --json
```
## Tags
```bash
prowlarr-cli tags # list all tags
prowlarr-cli tags --json
```
## Test All
```bash
prowlarr-cli test-all # trigger a test of all indexers
prowlarr-cli --dry-run test-all # preview
```
Triggers a `TestAllIndexers` command via the command API. Returns immediately — tests run async.
## JSON Output
All commands accept `--json`. Pipe to `jq` for scripting:
```bash
prowlarr-cli --json indexers | jq '.indexers[] | {name, protocol, enable, priority}'
prowlarr-cli --json history | jq '.history[] | {date, title, indexer, eventType}'
prowlarr-cli --json health | jq '.[] | {type, message}'
```
@@ -1,45 +0,0 @@
---
name: prowlarr-cli/troubleshooting
description: "Common pitfalls and FAQs for prowlarr-cli."
---
# Troubleshooting & FAQs
## Common Pitfalls
### Prowlarr is infrastructure, not media
Unlike radarr-cli, sonarr-cli, and lidarr-cli, prowlarr-cli doesn't browse or
add media. It manages the indexer layer that those apps use to find content.
### Indexer statistics may be empty on new setups
If Prowlarr was just set up or has few queries, `indexer-stats` may return
minimal data. This is normal — stats accumulate over time as searches run.
### Test all runs asynchronously
`prowlarr-cli test-all` triggers the command and returns immediately. The actual
tests run in the background. Check `prowlarr-cli indexer-status` to see results.
### Port 9696
Prowlarr defaults to port 9696 (not 7878, 8989, or 8686 like the other arrs).
## When Not to Use
- **Media search/download** — Prowlarr feeds indexers to other arrs but doesn't download media.
- **First-time Prowlarr setup** — install and configure Prowlarr first (prowlarr.com).
## FAQ
**Q: What's the difference between indexer-stats and indexer-status?**
A: `indexer-stats` shows cumulative query/grabs counts. `indexer-status` shows
current health (which indexers are disabled, when they'll retry).
**Q: Can I add or remove indexers with this CLI?**
A: Not yet. This CLI is read-only for now (list, inspect, test). Use the
Prowlarr web UI for adding/editing indexers.
**Q: How do I find an indexer's ID?**
A: Run `prowlarr-cli --json indexers | jq '.indexers[] | {name, id}'`
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""prowlarr-cli — Prowlarr indexer management from the terminal."""
import argparse, json, os, sys, warnings
from typing import Any, Dict, List
warnings.simplefilter("ignore")
import requests
ENV_SERVER = os.getenv("ARR_SERVER_PROWLARR", "http://localhost:9696")
ENV_KEY = os.getenv("ARR_KEY_PROWLARR", "")
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 ProwlarrClient:
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/v1{path}"
if self.dry_run: return {"dry_run":True, "url":url, "params":params}
if not self.key: die("ARR_KEY_PROWLARR not set. Get one from Prowlarr → 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_PROWLARR.")
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/v1{path}"
if self.dry_run: return {"dry_run":True, "url":url, "json":data}
if not self.key: die("ARR_KEY_PROWLARR not set.")
try:
r = requests.post(url, json=data, headers={"X-Api-Key":self.key,"Content-Type":"application/json"}, timeout=60)
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 indexers(self): return self._get("/indexer")
def indexer(self, idx_id): return self._get(f"/indexer/{idx_id}")
def indexer_stats(self): return self._get("/indexerstats")
def indexer_status(self): return self._get("/indexerstatus")
def applications(self): return self._get("/applications")
def download_clients(self): return self._get("/downloadclient")
def history(self, page=1, limit=20): return self._get("/history", {"page":page, "pageSize":limit})
def health(self): return self._get("/health")
def tags(self): return self._get("/tag")
def command(self, name, **kwargs):
return self._post("/command", {"name":name, **kwargs})
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"🔍 Prowlarr v{d.get('version','?')} OS: {d.get('osName','?')} DB: {d.get('databaseVersion','?')}",
{"version":d.get("version"),"osName":d.get("osName")})
def cmd_indexers(client, args):
p = argparse.ArgumentParser(prog="prowlarr-cli indexers")
p.add_argument("--limit", type=int, default=50)
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit("[dry-run] List indexers", {"dry_run":True})
data = client.indexers() or []
data = data[:parsed.limit]
if not data: return emit("No indexers configured.", {"indexers":[]})
lines, out = [], []
for idx in data:
name = idx.get("name","?"); protocol = idx.get("protocol","?")
enabled = idx.get("enable",False); priority = idx.get("priority",0)
status = idx.get("status","?")
icon = "✅" if enabled else "🚫"
lines.append(f" {icon} {name:35} {protocol:7} pri={priority} [{status}]")
out.append({"name":name,"protocol":protocol,"enable":enabled,"priority":priority,
"status":status,"id":idx.get("id"),"implementation":idx.get("implementationName")})
emit(f"{len(data)} indexer(s):\n"+"\n".join(lines), {"indexers":out})
def cmd_indexer(client, args):
p = argparse.ArgumentParser(prog="prowlarr-cli indexer")
p.add_argument("id", type=int, help="Indexer ID")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit(f"[dry-run] Get indexer {parsed.id}", {"dry_run":True})
idx = client.indexer(parsed.id) or {}
lines = [
f" {idx.get('name','?')} (id={idx.get('id','?')})",
f" Implementation: {idx.get('implementationName','?')} Protocol: {idx.get('protocol','?')}",
f" Enabled: {idx.get('enable',False)} Priority: {idx.get('priority',0)}",
f" Status: {idx.get('status','?')}",
f" Language: {idx.get('language','?')}",
]
emit("\n".join(lines), idx)
def cmd_indexer_stats(client, args):
if client.dry_run: return emit("[dry-run] Get indexer statistics", {"dry_run":True})
data = client.indexer_stats() or {}
lines = [f" Total queries: {data.get('totalQueryCount',0)}"]
for idx in data.get("indexers",[]):
name = idx.get("indexerName","?")
q = idx.get("queryCount",0); g = idx.get("grabCount",0)
avg = idx.get("averageResponseTime",0)
lines.append(f" {name:35} {q:5} queries {g:5} grabs {avg}ms avg")
emit("\n".join(lines) if len(lines)>1 else "No statistics available.", data)
def cmd_indexer_status(client, args):
if client.dry_run: return emit("[dry-run] Get indexer status", {"dry_run":True})
data = client.indexer_status() or []
if not data: return emit("All indexers OK.", {"indexerStatus":[]})
lines = [f" {s.get('indexerName','?'):35} [{s.get('status','?')}] {s.get('disabledTillUser','?') or 'ok'}" for s in data]
emit(f"{len(data)} indexer(s) with status:\n"+"\n".join(lines), {"indexerStatus":data})
def cmd_applications(client, args):
if client.dry_run: return emit("[dry-run] List applications", {"dry_run":True})
data = client.applications() or []
if not data: return emit("No applications connected.", {"applications":[]})
lines, out = [], []
for app in data:
name = app.get("name","?"); sync = app.get("syncLevel","?")
enabled = app.get("enabled",False)
icon = "✅" if enabled else "🚫"
lines.append(f" {icon} {name:30} sync={sync}")
out.append({"name":name,"syncLevel":sync,"enabled":enabled,"id":app.get("id")})
emit(f"{len(data)} application(s):\n"+"\n".join(lines), {"applications":out})
def cmd_download_clients(client, args):
if client.dry_run: return emit("[dry-run] List download clients", {"dry_run":True})
data = client.download_clients() or []
if not data: return emit("No download clients configured.", {"downloadClients":[]})
lines, out = [], []
for dc in data:
name = dc.get("name","?"); enabled = dc.get("enable",False)
protocol = dc.get("protocol","?")
icon = "✅" if enabled else "🚫"
lines.append(f" {icon} {name:30} {protocol:5}")
out.append({"name":name,"enable":enabled,"protocol":protocol,"id":dc.get("id")})
emit(f"{len(data)} download client(s):\n"+"\n".join(lines), {"downloadClients":out})
def cmd_history(client, args):
p = argparse.ArgumentParser(prog="prowlarr-cli history")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--event-type", help="Filter by event type")
parsed, _ = p.parse_known_args(args)
if client.dry_run: return emit("[dry-run] List history", {"dry_run":True})
data = client.history(limit=parsed.limit) or {}
records = data.get("records",[])
if parsed.event_type:
records = [r for r in records if r.get("eventType") == parsed.event_type]
if not records: return emit("No history.", {"history":[]})
lines, out = [], []
for e in records[:parsed.limit]:
idx_name = e.get("indexer",{}).get("name","?") if isinstance(e.get("indexer"),dict) else e.get("indexerName","?")
evt = e.get("eventType","?"); title = e.get("title","?")
date = (e.get("date") or "")[:10]
lines.append(f" {date} {title:40} {idx_name:25} {evt}")
out.append({"date":date,"title":title,"indexer":idx_name,"eventType":evt,"id":e.get("id")})
emit(f"History:\n"+"\n".join(lines), {"history":out})
def cmd_health(client, args):
if client.dry_run: return emit("[dry-run] Get health", {"dry_run":True})
data = client.health() or []
if not data: return emit("All healthy.", {"health":[]})
lines = [f" {h.get('type','?'):25} — {h.get('message','?')}" for h in data]
emit(f"{len(data)} warning(s):\n"+"\n".join(lines), {"health":data})
def cmd_tags(client, args):
if client.dry_run: return emit("[dry-run] List tags", {"dry_run":True})
data = client.tags() or []
if not data: return emit("No tags.", {"tags":[]})
lines = [f" {t.get('id')}: {t.get('label','?')}" for t in data]
emit(f"{len(data)} tag(s):\n"+"\n".join(lines), {"tags":data})
def cmd_test_all(client, args):
if client.dry_run: return emit("[dry-run] Test all indexers", {"dry_run":True})
data = client.command("TestAllIndexers")
emit(f"Testing triggered: command #{data.get('id')} — {data.get('status','?')}",
{"id":data.get("id"), "status":data.get("status"), "name":data.get("name")})
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="prowlarr-cli", description="Prowlarr indexer management.",
epilog="Set ARR_SERVER_PROWLARR and ARR_KEY_PROWLARR.")
sub = parser.add_subparsers(dest="command")
sub.add_parser("status")
sub.add_parser("indexers", help="List indexers").add_argument("--limit", type=int, default=50)
sub.add_parser("indexer", help="Get indexer details").add_argument("id", type=int)
sub.add_parser("indexer-stats", help="Indexer query/grab statistics")
sub.add_parser("indexer-status", help="Indexer health status")
sub.add_parser("applications", help="List connected *arr applications")
sub.add_parser("download-clients", help="List download clients")
sub.add_parser("history", help="Search history").add_argument("--limit", type=int, default=20)
sub.add_parser("health", help="Health warnings")
sub.add_parser("tags", help="List tags")
sub.add_parser("test-all", help="Test all indexers")
args = parser.parse_args(filtered_argv[1:])
if not args.command: parser.print_help(); sys.exit(1)
client = ProwlarrClient(dry_run=GLOBAL_FLAGS.get("dry_run",False))
handlers = {
"status":cmd_status, "indexers":cmd_indexers, "indexer":cmd_indexer,
"indexer-stats":cmd_indexer_stats, "indexer-status":cmd_indexer_status,
"applications":cmd_applications, "download-clients":cmd_download_clients,
"history":cmd_history, "health":cmd_health, "tags":cmd_tags,
"test-all":cmd_test_all,
}
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()
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
# test-prowlarr-cli.sh — Dry-run smoke tests for prowlarr-cli
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
fail() { FAIL=$((FAIL+1)); echo "$1"; }
check() {
local desc="$1" cmd="$2" pattern="$3"
output=$(eval "$cmd" 2>&1) && echo "$output" | grep -q "$pattern" && ok "$desc" || fail "$desc (expected '$pattern')"
}
echo "=== prowlarr-cli smoke tests ==="
echo ""
echo "--- help ---"
for cmd in status indexers indexer indexer-stats indexer-status applications download-clients history health tags test-all; do
check "help shows $cmd" "python3 $DIR/prowlarr-cli --help" "$cmd"
done
echo ""
echo "--- dry-run commands ---"
check "dry-run status" "python3 $DIR/prowlarr-cli --dry-run status" "dry-run"
check "dry-run indexers" "python3 $DIR/prowlarr-cli --dry-run indexers" "dry-run"
check "dry-run indexer (by id)" "python3 $DIR/prowlarr-cli --dry-run indexer 5" "dry-run"
check "dry-run indexer-stats" "python3 $DIR/prowlarr-cli --dry-run indexer-stats" "dry-run"
check "dry-run indexer-status" "python3 $DIR/prowlarr-cli --dry-run indexer-status" "dry-run"
check "dry-run applications" "python3 $DIR/prowlarr-cli --dry-run applications" "dry-run"
check "dry-run download-clients" "python3 $DIR/prowlarr-cli --dry-run download-clients" "dry-run"
check "dry-run history" "python3 $DIR/prowlarr-cli --dry-run history" "dry-run"
check "dry-run health" "python3 $DIR/prowlarr-cli --dry-run health" "dry-run"
check "dry-run tags" "python3 $DIR/prowlarr-cli --dry-run tags" "dry-run"
check "dry-run test-all" "python3 $DIR/prowlarr-cli --dry-run test-all" "dry-run.*Test all"
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
exit $FAIL