mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
chore: move arr skills to private repository
Signed-off-by: Magnus Hedemark <magnus919@pm.me>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
@@ -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}'
|
||||
```
|
||||
@@ -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}'
|
||||
```
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}'
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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}'`
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user