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 +21 -0
- package/README.md +279 -0
- package/config.example +39 -0
- package/install.sh +22 -0
- package/media +872 -0
- package/package.json +53 -0
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
|
+
}
|