arr-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solomon Neas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,279 @@
1
+ <p align="center">
2
+ <h1 align="center">📺 media-cli</h1>
3
+ <p align="center">
4
+ One script to manage your entire *arr media stack from the terminal.
5
+ <br />
6
+ <strong>Sonarr / Radarr / Prowlarr / qBittorrent / Bazarr / Jellyseerr</strong>
7
+ </p>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="#install">Install</a> •
12
+ <a href="#quick-start">Quick Start</a> •
13
+ <a href="#commands">Commands</a> •
14
+ <a href="#ai-agent-integration">AI Agents</a> •
15
+ <a href="#connection-modes">Remote/SSH</a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ **media-cli** is a single bash script that wraps the APIs of your entire media automation stack into simple, memorable commands. No Docker, no Node, no Python packages. Just `curl`, `python3` (for JSON parsing), and your existing *arr setup.
21
+
22
+ Built for humans who manage media servers from the terminal, and for AI agents that do it on their behalf.
23
+
24
+ ```bash
25
+ $ media movies search "Interstellar"
26
+ [157336] Interstellar (2014) 169min
27
+ The adventures of a group of explorers who make use of a newly discovered wormhole...
28
+
29
+ $ media movies add "Interstellar"
30
+ ✅ Added: Interstellar (2014) - Searching for downloads...
31
+
32
+ $ media downloads active
33
+ [ 23.4%] Interstellar.2014.1080p.BluRay.x265 (4.2 MB/s) 12m
34
+ ```
35
+
36
+ ## Supported Services
37
+
38
+ | Service | Status | What it does |
39
+ |---------|--------|-------------|
40
+ | [Sonarr](https://sonarr.tv) | Required | TV show search, add, monitor, manage |
41
+ | [Radarr](https://radarr.video) | Required | Movie search, add, monitor, manage |
42
+ | [Prowlarr](https://prowlarr.com) | Required | Indexer status and management |
43
+ | [qBittorrent](https://www.qbittorrent.org) | Required | Download monitoring and control |
44
+ | [Bazarr](https://www.bazarr.media) | Optional | Subtitle status and history |
45
+ | [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) | Optional | User requests and trending content |
46
+ | [Tdarr](https://tdarr.io) | Optional | Transcode monitoring (GPU/CPU worker progress) |
47
+
48
+ ## Requirements
49
+
50
+ - `bash` 4.0+
51
+ - `curl`
52
+ - `python3` (standard library only, no pip)
53
+ - `ssh` (only if using remote mode)
54
+
55
+ ## Install
56
+
57
+ **One-liner:**
58
+
59
+ ```bash
60
+ curl -fsSL https://raw.githubusercontent.com/solomonneas/media-cli/main/media -o ~/bin/media && chmod +x ~/bin/media
61
+ ```
62
+
63
+ **Or clone:**
64
+
65
+ ```bash
66
+ git clone https://github.com/solomonneas/media-cli.git
67
+ cd media-cli
68
+ bash install.sh
69
+ ```
70
+
71
+ Make sure `~/bin` is in your `PATH` (add `export PATH="$HOME/bin:$PATH"` to your shell profile if needed).
72
+
73
+ ## Quick Start
74
+
75
+ ```bash
76
+ # 1. Run the setup wizard
77
+ media setup
78
+
79
+ # 2. Test your connection
80
+ media status
81
+
82
+ # 3. Start using it
83
+ media movies search "The Matrix"
84
+ media shows add "Breaking Bad"
85
+ media downloads active
86
+ ```
87
+
88
+ The setup wizard asks for your API URLs and keys, then saves everything to `~/.config/media-cli/config`. You can also copy `config.example` and edit it by hand.
89
+
90
+ ### Finding Your API Keys
91
+
92
+ | Service | Where to find it |
93
+ |---------|-----------------|
94
+ | Sonarr | Settings > General > API Key |
95
+ | Radarr | Settings > General > API Key |
96
+ | Prowlarr | Settings > General > API Key |
97
+ | Bazarr | Settings > General > API Key |
98
+ | Jellyseerr | Settings > General > API Key |
99
+ | qBittorrent | Settings > Web UI > Username/Password |
100
+
101
+ Or grab them from the config files directly:
102
+
103
+ ```bash
104
+ # Linux
105
+ grep -i apikey ~/.config/Sonarr/config.xml
106
+ grep -i apikey ~/.config/Radarr/config.xml
107
+
108
+ # Windows
109
+ type C:\ProgramData\Sonarr\config.xml | findstr ApiKey
110
+
111
+ # Docker
112
+ docker exec sonarr cat /config/config.xml | grep ApiKey
113
+ ```
114
+
115
+ ## Commands
116
+
117
+ ### Library
118
+
119
+ ```bash
120
+ media movies list # List all movies with download status
121
+ media movies search "title" # Search online (via Radarr)
122
+ media movies add "title" # Add top result + start downloading
123
+ media movies remove "title" # Remove from library (keeps files)
124
+ media movies missing # Show monitored movies without files
125
+
126
+ media shows list # List all shows with episode counts
127
+ media shows search "title" # Search online (via Sonarr)
128
+ media shows add "title" # Add top result + search for episodes
129
+ media shows remove "title" # Remove from library (keeps files)
130
+ ```
131
+
132
+ ### Downloads
133
+
134
+ ```bash
135
+ media downloads # List all torrents grouped by state
136
+ media downloads active # Active downloads with speed + ETA
137
+ media downloads pause <hash|all>
138
+ media downloads resume <hash|all>
139
+ media downloads remove <hash> [true] # true = also delete files
140
+ ```
141
+
142
+ ### Monitoring
143
+
144
+ ```bash
145
+ media status # Service health + library counts + active downloads
146
+ media queue # Sonarr/Radarr download queues
147
+ media wanted # Missing episodes and movies
148
+ media calendar [days] # Upcoming releases (default: 7 days)
149
+ media history [sonarr|radarr|all] [limit]
150
+ media indexers # List Prowlarr indexers
151
+ media refresh [movies|shows|all] # Trigger library rescan
152
+ ```
153
+
154
+ ### Subtitles (Bazarr)
155
+
156
+ ```bash
157
+ media subs # Wanted subtitles for movies + episodes
158
+ media subs history # Recent subtitle downloads
159
+ ```
160
+
161
+ ### Requests (Jellyseerr)
162
+
163
+ ```bash
164
+ media requests # Pending user requests
165
+ media requests trending # What's trending
166
+ media requests users # User list with request counts
167
+ ```
168
+
169
+ ### Transcoding (Tdarr)
170
+
171
+ ```bash
172
+ media tdarr # Status, resources, active workers
173
+ media tdarr workers # Per-file progress: %, fps, size reduction, ETA
174
+ media tdarr queue # Items queued for processing
175
+ ```
176
+
177
+ ## Connection Modes
178
+
179
+ ### Local Mode
180
+
181
+ Services run on the same machine as the CLI:
182
+
183
+ ```bash
184
+ MEDIA_HOST="local"
185
+ ```
186
+
187
+ ### SSH Mode
188
+
189
+ Services run on a remote host (NAS, dedicated server, Windows box) and bind to `localhost`. The CLI runs curl commands over SSH:
190
+
191
+ ```bash
192
+ MEDIA_HOST="ssh:mediaserver" # Uses your SSH config alias
193
+ MEDIA_HOST_OS="linux" # or "windows"
194
+ ```
195
+
196
+ This is the killer feature for headless servers. Your services don't need to be exposed to the network. The CLI tunnels everything through SSH.
197
+
198
+ **Windows hosts work too.** POST requests automatically use PowerShell's `Invoke-RestMethod` when `MEDIA_HOST_OS="windows"`, so you don't need curl installed on the Windows side.
199
+
200
+ ## AI Agent Integration
201
+
202
+ This CLI was built alongside [OpenClaw](https://openclaw.ai), an AI agent platform. The commands are designed to be easily parsed by AI assistants.
203
+
204
+ Any AI agent or automation tool that can run shell commands can use media-cli:
205
+
206
+ **Natural language to commands:**
207
+
208
+ > "What shows am I missing episodes for?"
209
+ ```bash
210
+ media wanted
211
+ ```
212
+
213
+ > "Add Succession and start downloading it"
214
+ ```bash
215
+ media shows add "Succession"
216
+ ```
217
+
218
+ > "What's actively downloading right now?"
219
+ ```bash
220
+ media downloads active
221
+ ```
222
+
223
+ > "Pause all downloads"
224
+ ```bash
225
+ media downloads pause all
226
+ ```
227
+
228
+ Works with OpenClaw, LangChain tool calling, Claude computer use, or any agent framework that supports shell execution.
229
+
230
+ ## How It Works
231
+
232
+ ```
233
+ ┌──────────────┐ ┌─────────────────────────┐
234
+ │ media-cli │────▶│ SSH (optional) │
235
+ │ (your box) │ │ curl commands run on │
236
+ └──────────────┘ │ the media server │
237
+ └───────────┬─────────────┘
238
+
239
+ ┌────────────┼────────────┐
240
+ ▼ ▼ ▼
241
+ ┌────────┐ ┌────────┐ ┌──────────┐
242
+ │ Sonarr │ │ Radarr │ │ Prowlarr │
243
+ │ :8989 │ │ :7878 │ │ :9696 │
244
+ └────────┘ └────────┘ └──────────┘
245
+ │ │
246
+ ▼ ▼
247
+ ┌──────────────────────┐
248
+ │ qBittorrent │
249
+ │ :8080 │
250
+ └──────────────────────┘
251
+
252
+ ┌────┴────┐
253
+ ▼ ▼
254
+ ┌────────┐ ┌───────────┐ ┌───────┐
255
+ │ Bazarr │ │Jellyseerr │ │ Tdarr │
256
+ │ :6767 │ │ :5055 │ │ :8265 │
257
+ └────────┘ └───────────┘ └───────┘
258
+ ```
259
+
260
+ - Single bash script (~900 lines), no external dependencies
261
+ - Talks to *arr v3 APIs (Sonarr/Radarr), v1 (Prowlarr), v2 (qBittorrent WebUI)
262
+ - Python3 is used strictly for JSON parsing (standard library only)
263
+ - Config file is stored at `~/.config/media-cli/config` with `chmod 600`
264
+ - No telemetry, no analytics, no network calls except to your own services
265
+
266
+ ## Contributing
267
+
268
+ PRs welcome. Some ideas:
269
+
270
+ - [ ] Lidarr support (music)
271
+ - [ ] Readarr support (books)
272
+ - [ ] Tab completion (bash/zsh)
273
+ - [ ] Interactive mode (fzf-based search and select)
274
+ - [ ] Notification hooks (Discord/Telegram on download complete)
275
+ - [x] Tdarr integration (transcode status, worker progress, queue)
276
+
277
+ ## License
278
+
279
+ [MIT](LICENSE)
package/config.example ADDED
@@ -0,0 +1,39 @@
1
+ # media-cli configuration
2
+ # Copy to ~/.config/media-cli/config and fill in your values
3
+ # Or run `media setup` for interactive configuration
4
+
5
+ # Connection: "local" or "ssh:hostname"
6
+ # Use "local" if services run on this machine
7
+ # Use "ssh:myserver" to run commands over SSH (services bind to localhost on remote)
8
+ MEDIA_HOST="local"
9
+ MEDIA_HOST_OS="linux" # "linux" or "windows"
10
+
11
+ # Core services (required)
12
+ SONARR_URL="http://localhost:8989"
13
+ SONARR_KEY="your-sonarr-api-key"
14
+
15
+ RADARR_URL="http://localhost:7878"
16
+ RADARR_KEY="your-radarr-api-key"
17
+
18
+ PROWLARR_URL="http://localhost:9696"
19
+ PROWLARR_KEY="your-prowlarr-api-key"
20
+
21
+ QBIT_URL="http://localhost:8080"
22
+ QBIT_USER="admin"
23
+ QBIT_PASS="adminadmin"
24
+
25
+ # Optional services (leave key empty to disable)
26
+ BAZARR_URL="http://localhost:6767"
27
+ BAZARR_KEY=""
28
+
29
+ JELLYSEERR_URL="http://localhost:5055"
30
+ JELLYSEERR_KEY=""
31
+
32
+ FLARESOLVERR_URL="http://localhost:8191"
33
+ TDARR_URL="http://localhost:8265"
34
+
35
+ # Defaults
36
+ DEFAULT_MOVIE_QUALITY=4 # Quality profile ID (4 = HD-1080p typically)
37
+ DEFAULT_SHOW_QUALITY=4
38
+ DEFAULT_MOVIE_ROOT="/movies" # Root folder path for new movies
39
+ DEFAULT_SHOW_ROOT="/tv" # Root folder path for new shows
package/install.sh ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # media-cli installer
3
+ set -euo pipefail
4
+
5
+ INSTALL_DIR="${1:-$HOME/bin}"
6
+
7
+ echo "Installing media-cli to $INSTALL_DIR..."
8
+
9
+ mkdir -p "$INSTALL_DIR"
10
+ cp media "$INSTALL_DIR/media"
11
+ chmod +x "$INSTALL_DIR/media"
12
+
13
+ # Check if install dir is in PATH
14
+ if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
15
+ echo ""
16
+ echo "⚠️ $INSTALL_DIR is not in your PATH."
17
+ echo " Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):"
18
+ echo " export PATH=\"$INSTALL_DIR:\$PATH\""
19
+ fi
20
+
21
+ echo ""
22
+ echo "✅ Installed! Run 'media setup' to configure."
package/media ADDED
@@ -0,0 +1,872 @@
1
+ #!/usr/bin/env bash
2
+ # media-cli - CLI wrapper for the *arr media stack
3
+ # https://github.com/solomonneas/media-cli
4
+ #
5
+ # Manages Sonarr, Radarr, Prowlarr, qBittorrent, Bazarr, Jellyseerr,
6
+ # and companion services via SSH or locally.
7
+ #
8
+ # Configuration: ~/.config/media-cli/config
9
+ # Run `media setup` to generate a config interactively.
10
+
11
+ set -euo pipefail
12
+
13
+ # ── Config Loading ──────────────────────────────────────────────────────────
14
+ CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/media-cli"
15
+ CONFIG_FILE="$CONFIG_DIR/config"
16
+
17
+ _load_config() {
18
+ if [ ! -f "$CONFIG_FILE" ]; then
19
+ echo "Error: No config found at $CONFIG_FILE"
20
+ echo "Run 'media setup' to create one, or copy the example config."
21
+ exit 1
22
+ fi
23
+ # shellcheck source=/dev/null
24
+ source "$CONFIG_FILE"
25
+ }
26
+
27
+ # ── Connection Mode ─────────────────────────────────────────────────────────
28
+ # Supports two modes:
29
+ # MEDIA_HOST="ssh:hostname" - Run curl commands over SSH (services on remote host)
30
+ # MEDIA_HOST="local" - Run curl commands locally (services on this machine)
31
+
32
+ _curl() {
33
+ local url="$1"; shift
34
+ local extra="${*:-}"
35
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
36
+ local host="${MEDIA_HOST#ssh:}"
37
+ ssh "$host" "curl -sf --max-time 15 $extra \"$url\"" 2>/dev/null
38
+ else
39
+ curl -sf --max-time 15 $extra "$url" 2>/dev/null
40
+ fi
41
+ }
42
+
43
+ _curl_post() {
44
+ local url="$1"
45
+ local data="$2"
46
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
47
+ local host="${MEDIA_HOST#ssh:}"
48
+ local b64
49
+ b64=$(echo "$data" | base64 -w0)
50
+ # Use PowerShell on Windows SSH hosts, curl on Linux
51
+ if [ "${MEDIA_HOST_OS:-linux}" = "windows" ]; then
52
+ ssh "$host" "powershell -Command \"Invoke-RestMethod -Uri '$url' -Method Post -Headers @{'X-Api-Key'='${3:-}';'Content-Type'='application/json'} -Body ([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$b64')))\"" 2>/dev/null
53
+ else
54
+ ssh "$host" "echo '$data' | curl -sf --max-time 15 -X POST -H 'Content-Type: application/json' -H 'X-Api-Key: ${3:-}' -d @- '$url'" 2>/dev/null
55
+ fi
56
+ else
57
+ echo "$data" | curl -sf --max-time 15 -X POST -H "Content-Type: application/json" -H "X-Api-Key: ${3:-}" -d @- "$url" 2>/dev/null
58
+ fi
59
+ }
60
+
61
+ _powershell() {
62
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
63
+ local host="${MEDIA_HOST#ssh:}"
64
+ ssh "$host" "powershell -Command \"$1\"" 2>/dev/null
65
+ else
66
+ powershell -Command "$1" 2>/dev/null
67
+ fi
68
+ }
69
+
70
+ # ── API Helpers ─────────────────────────────────────────────────────────────
71
+
72
+ _sonarr() {
73
+ local endpoint="$1"; shift
74
+ _curl "${SONARR_URL}/api/v3/$endpoint" "-H \"X-Api-Key: $SONARR_KEY\"" "$@"
75
+ }
76
+
77
+ _radarr() {
78
+ local endpoint="$1"; shift
79
+ _curl "${RADARR_URL}/api/v3/$endpoint" "-H \"X-Api-Key: $RADARR_KEY\"" "$@"
80
+ }
81
+
82
+ _prowlarr() {
83
+ local endpoint="$1"; shift
84
+ _curl "${PROWLARR_URL}/api/v1/$endpoint" "-H \"X-Api-Key: $PROWLARR_KEY\"" "$@"
85
+ }
86
+
87
+ _bazarr() {
88
+ local endpoint="$1"; shift
89
+ _curl "${BAZARR_URL}/api/$endpoint" "-H \"X-Api-Key: $BAZARR_KEY\"" "$@"
90
+ }
91
+
92
+ _jellyseerr() {
93
+ local endpoint="$1"; shift
94
+ _curl "${JELLYSEERR_URL}/api/v1/$endpoint" "-H \"X-Api-Key: $JELLYSEERR_KEY\"" "$@"
95
+ }
96
+
97
+ _qbit_login() {
98
+ local sid
99
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
100
+ local host="${MEDIA_HOST#ssh:}"
101
+ sid=$(ssh "$host" "curl -sf --max-time 10 -c - \"$QBIT_URL/api/v2/auth/login\" -d \"username=$QBIT_USER&password=$QBIT_PASS\"" 2>/dev/null | grep -oP 'SID\t\K.*')
102
+ else
103
+ sid=$(curl -sf --max-time 10 -c - "$QBIT_URL/api/v2/auth/login" -d "username=$QBIT_USER&password=$QBIT_PASS" 2>/dev/null | grep -oP 'SID\t\K.*')
104
+ fi
105
+ echo "$sid"
106
+ }
107
+
108
+ _qbit() {
109
+ local endpoint="$1"; shift
110
+ local extra="${*:-}"
111
+ local sid
112
+ sid=$(_qbit_login)
113
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
114
+ local host="${MEDIA_HOST#ssh:}"
115
+ ssh "$host" "curl -sf --max-time 15 -b \"SID=$sid\" \"$QBIT_URL/api/v2/$endpoint\" $extra" 2>/dev/null
116
+ else
117
+ curl -sf --max-time 15 -b "SID=$sid" "$QBIT_URL/api/v2/$endpoint" $extra 2>/dev/null
118
+ fi
119
+ }
120
+
121
+ # ── Setup ───────────────────────────────────────────────────────────────────
122
+
123
+ cmd_setup() {
124
+ echo "=== media-cli Setup ==="
125
+ echo ""
126
+ mkdir -p "$CONFIG_DIR"
127
+
128
+ echo "Connection mode:"
129
+ echo " 1) Local - services run on this machine"
130
+ echo " 2) SSH - services run on a remote host"
131
+ read -rp "Choice [1]: " mode_choice
132
+ mode_choice="${mode_choice:-1}"
133
+
134
+ local media_host="local"
135
+ local media_host_os="linux"
136
+ if [ "$mode_choice" = "2" ]; then
137
+ read -rp "SSH host (alias or user@host): " ssh_host
138
+ media_host="ssh:$ssh_host"
139
+ read -rp "Remote OS (linux/windows) [linux]: " ros
140
+ media_host_os="${ros:-linux}"
141
+ fi
142
+
143
+ echo ""
144
+ echo "Enter API URLs and keys (press Enter to skip optional services):"
145
+ echo ""
146
+
147
+ read -rp "Sonarr URL [http://localhost:8989]: " sonarr_url
148
+ sonarr_url="${sonarr_url:-http://localhost:8989}"
149
+ read -rp "Sonarr API Key: " sonarr_key
150
+
151
+ read -rp "Radarr URL [http://localhost:7878]: " radarr_url
152
+ radarr_url="${radarr_url:-http://localhost:7878}"
153
+ read -rp "Radarr API Key: " radarr_key
154
+
155
+ read -rp "Prowlarr URL [http://localhost:9696]: " prowlarr_url
156
+ prowlarr_url="${prowlarr_url:-http://localhost:9696}"
157
+ read -rp "Prowlarr API Key: " prowlarr_key
158
+
159
+ read -rp "qBittorrent URL [http://localhost:8080]: " qbit_url
160
+ qbit_url="${qbit_url:-http://localhost:8080}"
161
+ read -rp "qBittorrent Username [admin]: " qbit_user
162
+ qbit_user="${qbit_user:-admin}"
163
+ read -rp "qBittorrent Password [adminadmin]: " qbit_pass
164
+ qbit_pass="${qbit_pass:-adminadmin}"
165
+
166
+ echo ""
167
+ echo "Optional services (press Enter to skip):"
168
+ read -rp "Bazarr URL [http://localhost:6767]: " bazarr_url
169
+ read -rp "Bazarr API Key: " bazarr_key
170
+ read -rp "Jellyseerr URL [http://localhost:5055]: " jellyseerr_url
171
+ read -rp "Jellyseerr API Key: " jellyseerr_key
172
+ read -rp "Tdarr URL [http://localhost:8265]: " tdarr_url
173
+ read -rp "FlareSolverr URL [http://localhost:8191]: " flaresolverr_url
174
+
175
+ echo ""
176
+ read -rp "Default quality profile ID [4]: " quality_id
177
+ quality_id="${quality_id:-4}"
178
+ read -rp "Default movie root folder [/movies]: " movie_root
179
+ movie_root="${movie_root:-/movies}"
180
+ read -rp "Default show root folder [/tv]: " show_root
181
+ show_root="${show_root:-/tv}"
182
+
183
+ cat > "$CONFIG_FILE" <<CONF
184
+ # media-cli configuration
185
+ # Generated by 'media setup' on $(date -Iseconds)
186
+
187
+ # Connection: "local" or "ssh:hostname"
188
+ MEDIA_HOST="$media_host"
189
+ MEDIA_HOST_OS="$media_host_os"
190
+
191
+ # Core services (required)
192
+ SONARR_URL="${sonarr_url}"
193
+ SONARR_KEY="${sonarr_key}"
194
+ RADARR_URL="${radarr_url}"
195
+ RADARR_KEY="${radarr_key}"
196
+ PROWLARR_URL="${prowlarr_url}"
197
+ PROWLARR_KEY="${prowlarr_key}"
198
+ QBIT_URL="${qbit_url}"
199
+ QBIT_USER="${qbit_user}"
200
+ QBIT_PASS="${qbit_pass}"
201
+
202
+ # Optional services
203
+ BAZARR_URL="${bazarr_url:-http://localhost:6767}"
204
+ BAZARR_KEY="${bazarr_key:-}"
205
+ JELLYSEERR_URL="${jellyseerr_url:-http://localhost:5055}"
206
+ JELLYSEERR_KEY="${jellyseerr_key:-}"
207
+ TDARR_URL="${tdarr_url:-http://localhost:8265}"
208
+ FLARESOLVERR_URL="${flaresolverr_url:-http://localhost:8191}"
209
+
210
+ # Defaults
211
+ DEFAULT_MOVIE_QUALITY=${quality_id}
212
+ DEFAULT_SHOW_QUALITY=${quality_id}
213
+ DEFAULT_MOVIE_ROOT="${movie_root}"
214
+ DEFAULT_SHOW_ROOT="${show_root}"
215
+ CONF
216
+
217
+ chmod 600 "$CONFIG_FILE"
218
+ echo ""
219
+ echo "✅ Config saved to $CONFIG_FILE"
220
+ echo " Run 'media status' to test your connection."
221
+ }
222
+
223
+ # ── Commands ────────────────────────────────────────────────────────────────
224
+
225
+ cmd_status() {
226
+ echo "=== Media Stack Status ==="
227
+ echo ""
228
+
229
+ local sv rv pv qv
230
+ sv=$(_sonarr "system/status" | python3 -c "import sys,json; print(f'v{json.load(sys.stdin)[\"version\"]}')" 2>/dev/null) && \
231
+ echo "✅ Sonarr $sv" || echo "❌ Sonarr DOWN"
232
+ rv=$(_radarr "system/status" | python3 -c "import sys,json; print(f'v{json.load(sys.stdin)[\"version\"]}')" 2>/dev/null) && \
233
+ echo "✅ Radarr $rv" || echo "❌ Radarr DOWN"
234
+ pv=$(_prowlarr "system/status" | python3 -c "import sys,json; print(f'v{json.load(sys.stdin)[\"version\"]}')" 2>/dev/null) && \
235
+ echo "✅ Prowlarr $pv" || echo "❌ Prowlarr DOWN"
236
+ qv=$(_qbit "app/version" 2>/dev/null) && \
237
+ echo "✅ qBit $qv" || echo "❌ qBit DOWN"
238
+
239
+ # Optional services
240
+ if [ -n "${BAZARR_KEY:-}" ]; then
241
+ local bv
242
+ bv=$(_bazarr "system/status" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'v{d.get(\"data\",d).get(\"bazarr_version\",\"?\")}')" 2>/dev/null) && \
243
+ echo "✅ Bazarr $bv" || echo "❌ Bazarr DOWN"
244
+ fi
245
+ if [ -n "${JELLYSEERR_KEY:-}" ]; then
246
+ local jv
247
+ jv=$(_jellyseerr "status" | python3 -c "import sys,json; print(f'v{json.load(sys.stdin).get(\"version\",\"?\")}')" 2>/dev/null) && \
248
+ echo "✅ Jellyseerr $jv" || echo "❌ Jellyseerr DOWN"
249
+ fi
250
+
251
+ echo ""
252
+ echo "=== Library ==="
253
+ local mc sc
254
+ mc=$(_radarr "movie" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) || mc="?"
255
+ sc=$(_sonarr "series" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null) || sc="?"
256
+ echo " Movies: $mc | Shows: $sc"
257
+
258
+ echo ""
259
+ echo "=== Active Downloads ==="
260
+ _qbit "torrents/info" "-d 'filter=active'" | python3 -c "
261
+ import sys,json
262
+ data = json.load(sys.stdin)
263
+ if not data:
264
+ print(' No active downloads')
265
+ else:
266
+ for t in data[:10]:
267
+ pct = round(t['progress']*100,1)
268
+ speed = t.get('dlspeed',0)
269
+ speed_str = f'{speed/1024/1024:.1f} MB/s' if speed > 0 else 'stalled'
270
+ print(f' [{pct:5.1f}%] {t[\"name\"][:60]} ({speed_str})')
271
+ if len(data) > 10:
272
+ print(f' ... and {len(data)-10} more')
273
+ " 2>/dev/null || echo " Could not fetch downloads"
274
+ }
275
+
276
+ cmd_movies() {
277
+ local subcmd="${1:-list}"; shift 2>/dev/null || true
278
+ case "$subcmd" in
279
+ list)
280
+ _radarr "movie" | python3 -c "
281
+ import sys,json
282
+ movies = sorted(json.load(sys.stdin), key=lambda m: m.get('sortTitle',''))
283
+ for m in movies:
284
+ status = '✅' if m.get('hasFile') else '⏳'
285
+ year = m.get('year','?')
286
+ quality = m.get('movieFile',{}).get('quality',{}).get('quality',{}).get('name','') if m.get('hasFile') else ''
287
+ print(f'{status} {m[\"title\"]} ({year}) {quality}')
288
+ print(f'\nTotal: {len(movies)} movies')
289
+ " 2>/dev/null
290
+ ;;
291
+ search)
292
+ local query="$*"
293
+ [ -z "$query" ] && { echo "Usage: media movies search <title>"; return 1; }
294
+ local eq
295
+ eq=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$query'''))")
296
+ _radarr "movie/lookup?term=$eq" | python3 -c "
297
+ import sys,json
298
+ results = json.load(sys.stdin)[:10]
299
+ for r in results:
300
+ year = r.get('year','?')
301
+ tmdb = r.get('tmdbId','?')
302
+ added = '➕' if r.get('id') else ''
303
+ runtime = r.get('runtime',0)
304
+ print(f'{added} [{tmdb}] {r[\"title\"]} ({year}) {runtime}min')
305
+ if r.get('overview'):
306
+ print(f' {r[\"overview\"][:120]}...')
307
+ if not results: print('No results found.')
308
+ " 2>/dev/null
309
+ ;;
310
+ add)
311
+ local query="$*"
312
+ [ -z "$query" ] && { echo "Usage: media movies add <title>"; return 1; }
313
+ local eq
314
+ eq=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$query'''))")
315
+ local lookup payload
316
+ lookup=$(_radarr "movie/lookup?term=$eq")
317
+ payload=$(echo "$lookup" | python3 -c "
318
+ import sys,json
319
+ results = json.load(sys.stdin)
320
+ if not results: print('ERROR:No results found'); sys.exit(1)
321
+ r = results[0]
322
+ if r.get('id'): print(f'EXISTING:{r[\"title\"]} ({r.get(\"year\",\"?\")}) already in library'); sys.exit(0)
323
+ movie = {'title':r['title'],'qualityProfileId':${DEFAULT_MOVIE_QUALITY},'tmdbId':r['tmdbId'],
324
+ 'titleSlug':r.get('titleSlug',''),'images':r.get('images',[]),'year':r.get('year',0),
325
+ 'rootFolderPath':'${DEFAULT_MOVIE_ROOT}','monitored':True,'addOptions':{'searchForMovie':True}}
326
+ print('ADD:'+json.dumps(movie))
327
+ " 2>/dev/null)
328
+ case "$payload" in
329
+ ERROR:*) echo "${payload#ERROR:}"; return 1 ;;
330
+ EXISTING:*) echo "${payload#EXISTING:}" ;;
331
+ ADD:*)
332
+ local json_data="${payload#ADD:}"
333
+ _curl_post "${RADARR_URL}/api/v3/movie" "$json_data" "$RADARR_KEY" | python3 -c "
334
+ import sys,json
335
+ r = json.load(sys.stdin)
336
+ print(f'✅ Added: {r[\"title\"]} ({r.get(\"year\",\"?\")}) - Searching for downloads...')
337
+ " 2>/dev/null || echo "✅ Added movie - searching for downloads..."
338
+ ;;
339
+ esac
340
+ ;;
341
+ remove)
342
+ local query="$*"
343
+ [ -z "$query" ] && { echo "Usage: media movies remove <title>"; return 1; }
344
+ _radarr "movie" | python3 -c "
345
+ import sys,json
346
+ query = '''$query'''.lower()
347
+ movies = [m for m in json.load(sys.stdin) if query in m['title'].lower()]
348
+ if not movies: print('ERROR:No match'); sys.exit(1)
349
+ if len(movies) > 1:
350
+ for m in movies: print(f'MULTI:{m[\"id\"]}: {m[\"title\"]} ({m.get(\"year\",\"?\")})')
351
+ else: print(f'REMOVE:{movies[0][\"id\"]}:{movies[0][\"title\"]} ({movies[0].get(\"year\",\"?\")})')
352
+ " 2>/dev/null | while IFS= read -r line; do
353
+ case "$line" in
354
+ ERROR:*) echo "${line#ERROR:}" ;;
355
+ MULTI:*) echo "Multiple matches: ${line#MULTI:}" ;;
356
+ REMOVE:*)
357
+ local id="${line#REMOVE:}"; id="${id%%:*}"; local name="${line#*:*:}"
358
+ _curl "${RADARR_URL}/api/v3/movie/$id?deleteFiles=false" "-X DELETE -H \"X-Api-Key: $RADARR_KEY\""
359
+ echo "🗑️ Removed: $name (files kept)" ;;
360
+ esac
361
+ done
362
+ ;;
363
+ missing)
364
+ _radarr "movie" | python3 -c "
365
+ import sys,json
366
+ movies = json.load(sys.stdin)
367
+ missing = sorted([m for m in movies if not m.get('hasFile') and m.get('monitored')], key=lambda m: m.get('sortTitle',''))
368
+ for m in missing: print(f'⏳ {m[\"title\"]} ({m.get(\"year\",\"?\")})')
369
+ print(f'\nMissing: {len(missing)} / {len(movies)} total')
370
+ " 2>/dev/null
371
+ ;;
372
+ *) echo "Usage: media movies [list|search|add|remove|missing]" ;;
373
+ esac
374
+ }
375
+
376
+ cmd_shows() {
377
+ local subcmd="${1:-list}"; shift 2>/dev/null || true
378
+ case "$subcmd" in
379
+ list)
380
+ _sonarr "series" | python3 -c "
381
+ import sys,json
382
+ shows = sorted(json.load(sys.stdin), key=lambda s: s.get('sortTitle',''))
383
+ for s in shows:
384
+ stats = s.get('statistics',{})
385
+ eps = stats.get('episodeFileCount',0)
386
+ total = stats.get('totalEpisodeCount',0)
387
+ icon = '✅' if eps == total else '⏳'
388
+ print(f'{icon} {s[\"title\"]} ({s.get(\"year\",\"?\")}) - {eps}/{total} eps [{s.get(\"status\",\"?\")}]')
389
+ print(f'\nTotal: {len(shows)} shows')
390
+ " 2>/dev/null
391
+ ;;
392
+ search)
393
+ local query="$*"
394
+ [ -z "$query" ] && { echo "Usage: media shows search <title>"; return 1; }
395
+ local eq
396
+ eq=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$query'''))")
397
+ _sonarr "series/lookup?term=$eq" | python3 -c "
398
+ import sys,json
399
+ results = json.load(sys.stdin)[:10]
400
+ for r in results:
401
+ added = '➕' if r.get('id') else ''
402
+ print(f'{added} [{r.get(\"tvdbId\",\"?\")}] {r[\"title\"]} ({r.get(\"year\",\"?\")}) - {r.get(\"seasonCount\",0)} seasons [{r.get(\"status\",\"?\")}]')
403
+ if r.get('overview'): print(f' {r[\"overview\"][:120]}...')
404
+ if not results: print('No results found.')
405
+ " 2>/dev/null
406
+ ;;
407
+ add)
408
+ local query="$*"
409
+ [ -z "$query" ] && { echo "Usage: media shows add <title>"; return 1; }
410
+ local eq
411
+ eq=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$query'''))")
412
+ local lookup payload
413
+ lookup=$(_sonarr "series/lookup?term=$eq")
414
+ payload=$(echo "$lookup" | python3 -c "
415
+ import sys,json
416
+ results = json.load(sys.stdin)
417
+ if not results: print('ERROR:No results found'); sys.exit(1)
418
+ r = results[0]
419
+ if r.get('id'): print(f'EXISTING:{r[\"title\"]} ({r.get(\"year\",\"?\")}) already in library'); sys.exit(0)
420
+ show = {'title':r['title'],'qualityProfileId':${DEFAULT_SHOW_QUALITY},'tvdbId':r['tvdbId'],
421
+ 'titleSlug':r.get('titleSlug',''),'images':r.get('images',[]),'seasons':r.get('seasons',[]),
422
+ 'year':r.get('year',0),'rootFolderPath':'${DEFAULT_SHOW_ROOT}','seasonFolder':True,
423
+ 'monitored':True,'addOptions':{'searchForMissingEpisodes':True}}
424
+ print('ADD:'+json.dumps(show))
425
+ " 2>/dev/null)
426
+ case "$payload" in
427
+ ERROR:*) echo "${payload#ERROR:}"; return 1 ;;
428
+ EXISTING:*) echo "${payload#EXISTING:}" ;;
429
+ ADD:*)
430
+ local json_data="${payload#ADD:}"
431
+ _curl_post "${SONARR_URL}/api/v3/series" "$json_data" "$SONARR_KEY" | python3 -c "
432
+ import sys,json
433
+ r = json.load(sys.stdin)
434
+ print(f'✅ Added: {r[\"title\"]} ({r.get(\"year\",\"?\")}) - Searching for episodes...')
435
+ " 2>/dev/null || echo "✅ Added show - searching for episodes..."
436
+ ;;
437
+ esac
438
+ ;;
439
+ remove)
440
+ local query="$*"
441
+ [ -z "$query" ] && { echo "Usage: media shows remove <title>"; return 1; }
442
+ _sonarr "series" | python3 -c "
443
+ import sys,json
444
+ query = '''$query'''.lower()
445
+ shows = [s for s in json.load(sys.stdin) if query in s['title'].lower()]
446
+ if not shows: print('ERROR:No match'); sys.exit(1)
447
+ if len(shows) > 1:
448
+ for s in shows: print(f'MULTI:{s[\"id\"]}: {s[\"title\"]} ({s.get(\"year\",\"?\")})')
449
+ else: print(f'REMOVE:{shows[0][\"id\"]}:{shows[0][\"title\"]} ({shows[0].get(\"year\",\"?\")})')
450
+ " 2>/dev/null | while IFS= read -r line; do
451
+ case "$line" in
452
+ ERROR:*) echo "${line#ERROR:}" ;;
453
+ MULTI:*) echo "Multiple matches: ${line#MULTI:}" ;;
454
+ REMOVE:*)
455
+ local id="${line#REMOVE:}"; id="${id%%:*}"; local name="${line#*:*:}"
456
+ _curl "${SONARR_URL}/api/v3/series/$id?deleteFiles=false" "-X DELETE -H \"X-Api-Key: $SONARR_KEY\""
457
+ echo "🗑️ Removed: $name (files kept)" ;;
458
+ esac
459
+ done
460
+ ;;
461
+ *) echo "Usage: media shows [list|search|add|remove]" ;;
462
+ esac
463
+ }
464
+
465
+ cmd_downloads() {
466
+ local subcmd="${1:-list}"; shift 2>/dev/null || true
467
+ case "$subcmd" in
468
+ list|"")
469
+ _qbit "torrents/info" | python3 -c "
470
+ import sys,json
471
+ data = json.load(sys.stdin)
472
+ if not data: print('No torrents.'); sys.exit(0)
473
+ states = {}
474
+ for t in data: states.setdefault(t.get('state','unknown'), []).append(t)
475
+ for state, torrents in sorted(states.items()):
476
+ print(f'\n=== {state.upper()} ({len(torrents)}) ===')
477
+ for t in sorted(torrents, key=lambda x: x['name']):
478
+ pct = round(t['progress']*100,1)
479
+ size = t.get('total_size',0)/(1024**3)
480
+ cat = f' [{t[\"category\"]}]' if t.get('category') else ''
481
+ print(f' [{pct:5.1f}%] {t[\"name\"][:65]}{cat} ({size:.1f}GB)')
482
+ print(f'\nTotal: {len(data)} torrents')
483
+ " 2>/dev/null
484
+ ;;
485
+ active)
486
+ _qbit "torrents/info" "-d 'filter=active'" | python3 -c "
487
+ import sys,json
488
+ data = json.load(sys.stdin)
489
+ if not data: print('No active downloads.'); sys.exit(0)
490
+ for t in data:
491
+ pct = round(t['progress']*100,1)
492
+ speed = t.get('dlspeed',0)
493
+ speed_str = f'{speed/1024/1024:.1f} MB/s' if speed > 0 else 'stalled'
494
+ eta = t.get('eta',0)
495
+ eta_str = f'{eta//3600}h{(eta%3600)//60}m' if 0 < eta < 864000 else ''
496
+ print(f'[{pct:5.1f}%] {t[\"name\"][:60]} ({speed_str}) {eta_str}')
497
+ print(f'\n{len(data)} active')
498
+ " 2>/dev/null
499
+ ;;
500
+ pause)
501
+ local hash="${1:-}"; [ -z "$hash" ] && { echo "Usage: media downloads pause <hash|all>"; return 1; }
502
+ _qbit "torrents/pause" "-d 'hashes=${hash}'"
503
+ echo "⏸️ Paused"
504
+ ;;
505
+ resume)
506
+ local hash="${1:-}"; [ -z "$hash" ] && { echo "Usage: media downloads resume <hash|all>"; return 1; }
507
+ _qbit "torrents/resume" "-d 'hashes=${hash}'"
508
+ echo "▶️ Resumed"
509
+ ;;
510
+ remove)
511
+ local hash="${1:-}"; [ -z "$hash" ] && { echo "Usage: media downloads remove <hash> [true]"; return 1; }
512
+ _qbit "torrents/delete" "-d 'hashes=$hash&deleteFiles=${2:-false}'"
513
+ echo "🗑️ Removed"
514
+ ;;
515
+ *) echo "Usage: media downloads [list|active|pause|resume|remove]" ;;
516
+ esac
517
+ }
518
+
519
+ cmd_indexers() {
520
+ _prowlarr "indexer" | python3 -c "
521
+ import sys,json
522
+ for i in json.load(sys.stdin):
523
+ s = '✅' if i.get('enable') else '❌'
524
+ print(f'{s} {i[\"name\"]} [{i.get(\"protocol\",\"?\")}] (ID: {i[\"id\"]})')
525
+ " 2>/dev/null
526
+ }
527
+
528
+ cmd_calendar() {
529
+ local days="${1:-7}"
530
+ local start end
531
+ start=$(date -u +%Y-%m-%dT00:00:00Z)
532
+ end=$(date -u -d "+${days} days" +%Y-%m-%dT00:00:00Z 2>/dev/null || date -u -v"+${days}d" +%Y-%m-%dT00:00:00Z)
533
+
534
+ echo "=== Upcoming Episodes (${days}d) ==="
535
+ _sonarr "calendar?start=$start&end=$end" | python3 -c "
536
+ import sys,json
537
+ for e in json.load(sys.stdin):
538
+ s=e.get('seasonNumber',0); ep=e.get('episodeNumber',0)
539
+ has='✅' if e.get('hasFile') else '⏳'
540
+ print(f'{has} {e.get(\"airDateUtc\",\"?\")[:10]} {e.get(\"series\",{}).get(\"title\",\"?\")} S{s:02d}E{ep:02d}')
541
+ " 2>/dev/null || echo " Nothing scheduled."
542
+
543
+ echo ""
544
+ echo "=== Upcoming Movies (${days}d) ==="
545
+ _radarr "calendar?start=$start&end=$end" | python3 -c "
546
+ import sys,json
547
+ for m in json.load(sys.stdin):
548
+ d=m.get('digitalRelease',m.get('physicalRelease',m.get('inCinemas','?')))
549
+ has='✅' if m.get('hasFile') else '⏳'
550
+ print(f'{has} {(d or \"?\")[:10]} {m[\"title\"]} ({m.get(\"year\",\"?\")})')
551
+ " 2>/dev/null || echo " Nothing scheduled."
552
+ }
553
+
554
+ cmd_history() {
555
+ local service="${1:-all}" limit="${2:-20}"
556
+ if [ "$service" = "all" ] || [ "$service" = "sonarr" ]; then
557
+ echo "=== Sonarr History ==="
558
+ _sonarr "history?pageSize=$limit&sortKey=date&sortDirection=descending" | python3 -c "
559
+ import sys,json
560
+ for r in json.load(sys.stdin).get('records',[]):
561
+ print(f' {r.get(\"date\",\"?\")[:16].replace(\"T\",\" \")} [{r.get(\"eventType\",\"?\")}] {r.get(\"series\",{}).get(\"title\",\"?\")} - {r.get(\"episode\",{}).get(\"title\",\"\")}')
562
+ " 2>/dev/null; echo ""; fi
563
+ if [ "$service" = "all" ] || [ "$service" = "radarr" ]; then
564
+ echo "=== Radarr History ==="
565
+ _radarr "history?pageSize=$limit&sortKey=date&sortDirection=descending" | python3 -c "
566
+ import sys,json
567
+ for r in json.load(sys.stdin).get('records',[]):
568
+ print(f' {r.get(\"date\",\"?\")[:16].replace(\"T\",\" \")} [{r.get(\"eventType\",\"?\")}] {r.get(\"movie\",{}).get(\"title\",\"?\")}')
569
+ " 2>/dev/null; fi
570
+ }
571
+
572
+ cmd_queue() {
573
+ echo "=== Sonarr Queue ==="
574
+ _sonarr "queue?pageSize=50" | python3 -c "
575
+ import sys,json
576
+ records = json.load(sys.stdin).get('records',[])
577
+ if not records: print(' Empty')
578
+ for r in records:
579
+ pct = round(100-(r.get('sizeleft',0)/max(r.get('size',1),1))*100,1)
580
+ print(f' [{pct:5.1f}%] {r.get(\"series\",{}).get(\"title\",\"?\")} - {r.get(\"episode\",{}).get(\"title\",\"\")} ({r.get(\"status\",\"?\")})')
581
+ " 2>/dev/null
582
+ echo ""
583
+ echo "=== Radarr Queue ==="
584
+ _radarr "queue?pageSize=50" | python3 -c "
585
+ import sys,json
586
+ records = json.load(sys.stdin).get('records',[])
587
+ if not records: print(' Empty')
588
+ for r in records:
589
+ pct = round(100-(r.get('sizeleft',0)/max(r.get('size',1),1))*100,1)
590
+ print(f' [{pct:5.1f}%] {r.get(\"movie\",{}).get(\"title\",\"?\")} ({r.get(\"status\",\"?\")})')
591
+ " 2>/dev/null
592
+ }
593
+
594
+ cmd_wanted() {
595
+ echo "=== Wanted Episodes ==="
596
+ _sonarr "wanted/missing?pageSize=20&sortKey=airDateUtc&sortDirection=descending" | python3 -c "
597
+ import sys,json
598
+ d=json.load(sys.stdin)
599
+ for r in d.get('records',[]):
600
+ s=r.get('seasonNumber',0); ep=r.get('episodeNumber',0)
601
+ print(f' {r.get(\"airDateUtc\",\"?\")[:10]} {r.get(\"series\",{}).get(\"title\",\"?\")} S{s:02d}E{ep:02d} - {r.get(\"title\",\"\")}')
602
+ print(f'\nTotal: {d.get(\"totalRecords\",0)}')
603
+ " 2>/dev/null
604
+ echo ""
605
+ echo "=== Wanted Movies ==="
606
+ _radarr "movie" | python3 -c "
607
+ import sys,json
608
+ missing = sorted([m for m in json.load(sys.stdin) if not m.get('hasFile') and m.get('monitored')], key=lambda m: m.get('title',''))
609
+ for m in missing[:20]: print(f' {m[\"title\"]} ({m.get(\"year\",\"?\")})')
610
+ print(f'\nTotal: {len(missing)}')
611
+ " 2>/dev/null
612
+ }
613
+
614
+ cmd_refresh() {
615
+ local target="${1:-all}"
616
+ case "$target" in
617
+ movies|radarr) _curl_post "${RADARR_URL}/api/v3/command" '{"name":"RefreshMovie"}' "$RADARR_KEY" >/dev/null; echo "🔄 Radarr refresh triggered" ;;
618
+ shows|sonarr) _curl_post "${SONARR_URL}/api/v3/command" '{"name":"RefreshSeries"}' "$SONARR_KEY" >/dev/null; echo "🔄 Sonarr refresh triggered" ;;
619
+ all) cmd_refresh movies; cmd_refresh shows ;;
620
+ *) echo "Usage: media refresh [movies|shows|all]" ;;
621
+ esac
622
+ }
623
+
624
+ cmd_subs() {
625
+ [ -z "${BAZARR_KEY:-}" ] && { echo "Bazarr not configured. Run 'media setup'."; return 1; }
626
+ local subcmd="${1:-status}"; shift 2>/dev/null || true
627
+ case "$subcmd" in
628
+ status)
629
+ _bazarr "system/status" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Bazarr v{d.get(\"data\",d).get(\"bazarr_version\",\"?\")}')" 2>/dev/null
630
+ echo "\n=== Wanted Subs (Movies) ==="
631
+ _bazarr "movies/wanted?start=0&length=15" | python3 -c "
632
+ import sys,json
633
+ d=json.load(sys.stdin); data=d.get('data',[])
634
+ for m in data[:15]:
635
+ langs=', '.join([s.get('name','?') for s in m.get('missing_subtitles',[])])
636
+ print(f' 🎬 {m.get(\"title\",\"?\")} - {langs}')
637
+ print(f'Total: {d.get(\"total\",len(data))}')
638
+ " 2>/dev/null
639
+ echo "\n=== Wanted Subs (Episodes) ==="
640
+ _bazarr "episodes/wanted?start=0&length=15" | python3 -c "
641
+ import sys,json
642
+ d=json.load(sys.stdin); data=d.get('data',[])
643
+ for e in data[:15]:
644
+ langs=', '.join([s.get('name','?') for s in e.get('missing_subtitles',[])])
645
+ print(f' 📺 {e.get(\"seriesTitle\",\"?\")} S{e.get(\"season\",0):02d}E{e.get(\"episode\",0):02d} - {langs}')
646
+ print(f'Total: {d.get(\"total\",len(data))}')
647
+ " 2>/dev/null
648
+ ;;
649
+ history)
650
+ _bazarr "history/episodes?start=0&length=${1:-20}" | python3 -c "
651
+ import sys,json
652
+ for h in json.load(sys.stdin).get('data',[]):
653
+ icon='✅' if h.get('action')==1 else '❌'
654
+ print(f'{icon} {h.get(\"timestamp\",\"\")[:16]} {h.get(\"seriesTitle\",\"?\")} [{h.get(\"language\",{}).get(\"name\",\"?\")}] via {h.get(\"provider\",\"?\")}')
655
+ " 2>/dev/null
656
+ ;;
657
+ *) echo "Usage: media subs [status|history]" ;;
658
+ esac
659
+ }
660
+
661
+ cmd_requests() {
662
+ [ -z "${JELLYSEERR_KEY:-}" ] && { echo "Jellyseerr not configured. Run 'media setup'."; return 1; }
663
+ local subcmd="${1:-list}"; shift 2>/dev/null || true
664
+ case "$subcmd" in
665
+ list)
666
+ _jellyseerr "request?take=20&skip=0&filter=pending&sort=added" | python3 -c "
667
+ import sys,json
668
+ d=json.load(sys.stdin)
669
+ for r in d.get('results',[]):
670
+ media=r.get('media',{}); title=media.get('title',media.get('name','?'))
671
+ s={1:'⏳',2:'✅',3:'❌'}.get(r.get('status',0),'?')
672
+ icon='🎬' if r.get('type')=='movie' else '📺'
673
+ print(f'{s} {icon} {title} - {r.get(\"requestedBy\",{}).get(\"displayName\",\"?\")}')
674
+ if not d.get('results'): print(' No pending requests')
675
+ " 2>/dev/null
676
+ ;;
677
+ trending)
678
+ _jellyseerr "discover/trending" | python3 -c "
679
+ import sys,json
680
+ for r in json.load(sys.stdin).get('results',[])[:15]:
681
+ icon='🎬' if r.get('mediaType')=='movie' else '📺'
682
+ title=r.get('title',r.get('name','?'))
683
+ year=str(r.get('releaseDate',r.get('firstAirDate','')))[:4]
684
+ print(f'{icon} {title} ({year})')
685
+ o=r.get('overview','')
686
+ if o: print(f' {o[:100]}...')
687
+ " 2>/dev/null
688
+ ;;
689
+ users)
690
+ _jellyseerr "user?take=50" | python3 -c "
691
+ import sys,json
692
+ for u in json.load(sys.stdin).get('results',[]):
693
+ role='Admin' if u.get('permissions',0)>=16 else 'User'
694
+ print(f' {u.get(\"displayName\",u.get(\"jellyfinUsername\",\"?\"))} [{role}] - {u.get(\"requestCount\",0)} requests')
695
+ " 2>/dev/null
696
+ ;;
697
+ *) echo "Usage: media requests [list|trending|users]" ;;
698
+ esac
699
+ }
700
+
701
+ cmd_tdarr() {
702
+ local tdarr_url="${TDARR_URL:-http://localhost:8265}"
703
+ local subcmd="${1:-status}"; shift 2>/dev/null || true
704
+
705
+ _tdarr_get() { _curl "${tdarr_url}/api/v2/$1" ""; }
706
+ _tdarr_post() {
707
+ if [[ "${MEDIA_HOST:-local}" == ssh:* ]]; then
708
+ local host="${MEDIA_HOST#ssh:}"
709
+ ssh "$host" "curl -sf --max-time 15 -X POST -H \"Content-Type: application/json\" -d \"{}\" \"${tdarr_url}/api/v2/$1\"" 2>/dev/null
710
+ else
711
+ curl -sf --max-time 15 -X POST -H "Content-Type: application/json" -d "{}" "${tdarr_url}/api/v2/$1" 2>/dev/null
712
+ fi
713
+ }
714
+
715
+ case "$subcmd" in
716
+ status)
717
+ echo "=== Tdarr Status ==="
718
+ local ver
719
+ ver=$(_tdarr_get "status" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'v{d[\"version\"]} ({d[\"os\"]})')" 2>/dev/null) || { echo "❌ Tdarr not reachable at $tdarr_url"; return 1; }
720
+ echo "✅ Tdarr $ver"
721
+ echo ""
722
+
723
+ _tdarr_post "get-res-stats" | python3 -c "
724
+ import sys,json
725
+ d = json.load(sys.stdin)
726
+ p = d.get('process',{}); o = d.get('os',{})
727
+ print('=== Server Resources ===')
728
+ print(f' CPU: {o.get(\"cpuPerc\",\"?\"):>6}%')
729
+ print(f' RAM: {o.get(\"memUsedGB\",\"?\")}/{o.get(\"memTotalGB\",\"?\")} GB')
730
+ print(f' Heap: {p.get(\"heapUsedMB\",\"?\")} MB / {p.get(\"heapTotalMB\",\"?\")} MB')
731
+ print(f' Uptime: {int(p.get(\"uptime\",0))//3600}h {(int(p.get(\"uptime\",0))%3600)//60}m')
732
+ " 2>/dev/null
733
+
734
+ echo ""
735
+ _tdarr_post "get-db-statuses" | python3 -c "
736
+ import sys,json
737
+ d = json.load(sys.stdin)
738
+ print('=== Database ===')
739
+ for k in ['LibrarySettingsJSONDB','FileJSONDB','StagedJSONDB','FlowsJSONDB']:
740
+ info = d.get(k,{})
741
+ label = k.replace('JSONDB','').replace('Settings','')
742
+ cnt = info.get('totalCount',0)
743
+ act = info.get('type','')
744
+ act_str = f' - {act}' if act else ''
745
+ print(f' {label:<12} {cnt:>5} items{act_str}')
746
+ " 2>/dev/null
747
+
748
+ echo ""
749
+ cmd_tdarr workers
750
+ ;;
751
+
752
+ workers)
753
+ echo "=== Tdarr Workers ==="
754
+ _tdarr_get "get-nodes" | python3 -c "
755
+ import sys,json
756
+ d = json.load(sys.stdin)
757
+ total = 0; active = 0
758
+ for nid, node in d.items():
759
+ if not isinstance(node, dict) or 'workers' not in node: continue
760
+ workers = node.get('workers', {})
761
+ node_total = len(workers)
762
+ node_active = sum(1 for w in workers.values() if isinstance(w,dict) and not w.get('idle',True))
763
+ total += node_total; active += node_active
764
+ print(f'Node: {node.get(\"nodeName\", nid)} ({node_active}/{node_total} workers active)')
765
+ for wid, w in workers.items():
766
+ if not isinstance(w, dict): continue
767
+ wtype = w.get('workerType','?')
768
+ if w.get('idle', True):
769
+ print(f' ⏸️ {wid} [{wtype}] idle')
770
+ else:
771
+ pct = w.get('percentage', 0)
772
+ orig = w.get('originalfileSizeInGbytes', 0)
773
+ out = w.get('outputFileSizeInGbytes', 0)
774
+ file = w.get('file','?')
775
+ fname = file.replace('\\\\','/').split('/')[-1][:65] if isinstance(file,str) else '?'
776
+ eta = w.get('ETA','?')
777
+ fps = w.get('fps','')
778
+ fps_str = f' @ {fps}fps' if fps else ''
779
+ reduction = round((1 - out/orig)*100, 1) if orig > 0 and out > 0 else 0
780
+ print(f' ⚙️ {wid} [{wtype}] {pct:.1f}%{fps_str}')
781
+ print(f' {fname}')
782
+ print(f' {orig:.2f}GB -> {out:.2f}GB ({reduction:.1f}% smaller) ETA: {eta}')
783
+ print(f'\nTotal: {active} active / {total} workers')
784
+ " 2>/dev/null
785
+ ;;
786
+
787
+ queue)
788
+ echo "=== Tdarr Queue ==="
789
+ _tdarr_post "get-db-statuses" | python3 -c "
790
+ import sys,json
791
+ d = json.load(sys.stdin)
792
+ files = d.get('FileJSONDB',{})
793
+ staged = d.get('StagedJSONDB',{})
794
+ print(f' Files indexed: {files.get(\"totalCount\",0)}')
795
+ print(f' Staged (queue): {staged.get(\"totalCount\",0)}')
796
+ act = staged.get('type','')
797
+ if act: print(f' Status: {act}')
798
+ " 2>/dev/null
799
+ ;;
800
+
801
+ *) echo "Usage: media tdarr [status|workers|queue]" ;;
802
+ esac
803
+ }
804
+
805
+ cmd_help() {
806
+ cat <<'EOF'
807
+ media-cli - CLI for the *arr media stack
808
+
809
+ USAGE: media <command> [subcommand] [args...]
810
+
811
+ SETUP:
812
+ setup Interactive configuration wizard
813
+
814
+ LIBRARY:
815
+ status Service health + library counts + active downloads
816
+ movies list|search|add|remove|missing
817
+ shows list|search|add|remove
818
+
819
+ DOWNLOADS:
820
+ downloads [list|active|pause|resume|remove]
821
+ queue Sonarr/Radarr download queues
822
+ wanted Missing episodes and movies
823
+
824
+ MONITORING:
825
+ calendar [days] Upcoming releases (default: 7)
826
+ history [sonarr|radarr|all] [limit]
827
+ indexers List Prowlarr indexers
828
+ refresh [movies|shows|all]
829
+
830
+ SUBTITLES (Bazarr):
831
+ subs [status|history]
832
+
833
+ REQUESTS (Jellyseerr):
834
+ requests [list|trending|users]
835
+
836
+ TRANSCODING (Tdarr):
837
+ tdarr [status] Server status, resources, active workers
838
+ tdarr workers Per-worker progress (file, %, fps, ETA, size savings)
839
+ tdarr queue Items queued for processing
840
+
841
+ REQUIREMENTS:
842
+ bash, curl, python3, ssh (for remote mode)
843
+
844
+ CONFIG: ~/.config/media-cli/config
845
+ EOF
846
+ }
847
+
848
+ # ── Main ────────────────────────────────────────────────────────────────────
849
+ cmd="${1:-help}"; shift 2>/dev/null || true
850
+
851
+ # Setup doesn't need config
852
+ [ "$cmd" = "setup" ] && { cmd_setup; exit 0; }
853
+ [ "$cmd" = "help" ] || [ "$cmd" = "--help" ] || [ "$cmd" = "-h" ] && { cmd_help; exit 0; }
854
+
855
+ _load_config
856
+
857
+ case "$cmd" in
858
+ status) cmd_status "$@" ;;
859
+ movies) cmd_movies "$@" ;;
860
+ shows) cmd_shows "$@" ;;
861
+ downloads|dl) cmd_downloads "$@" ;;
862
+ queue) cmd_queue "$@" ;;
863
+ wanted) cmd_wanted "$@" ;;
864
+ calendar|cal) cmd_calendar "$@" ;;
865
+ history) cmd_history "$@" ;;
866
+ indexers) cmd_indexers "$@" ;;
867
+ refresh) cmd_refresh "$@" ;;
868
+ subs|subtitles) cmd_subs "$@" ;;
869
+ requests|req) cmd_requests "$@" ;;
870
+ tdarr|transcode) cmd_tdarr "$@" ;;
871
+ *) echo "Unknown: $cmd"; cmd_help; exit 1 ;;
872
+ esac
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "arr-cli",
3
+ "version": "1.0.0",
4
+ "description": "Single-file bash CLI for the *arr media stack — Sonarr, Radarr, Prowlarr, qBittorrent, Bazarr, Jellyseerr, Tdarr, and more.",
5
+ "bin": {
6
+ "arr-cli": "./media",
7
+ "media": "./media"
8
+ },
9
+ "scripts": {
10
+ "test": "bash media help"
11
+ },
12
+ "files": [
13
+ "media",
14
+ "config.example",
15
+ "install.sh",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "sonarr",
21
+ "radarr",
22
+ "prowlarr",
23
+ "qbittorrent",
24
+ "bazarr",
25
+ "jellyseerr",
26
+ "tdarr",
27
+ "arr",
28
+ "media-server",
29
+ "cli",
30
+ "plex",
31
+ "jellyfin",
32
+ "usenet",
33
+ "torrent",
34
+ "media-management"
35
+ ],
36
+ "author": "Solomon Neas <me@solomonneas.dev>",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/solomonneas/media-cli.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/solomonneas/media-cli/issues"
44
+ },
45
+ "homepage": "https://github.com/solomonneas/media-cli#readme",
46
+ "engines": {
47
+ "node": ">=16"
48
+ },
49
+ "os": [
50
+ "linux",
51
+ "darwin"
52
+ ]
53
+ }