let a torrent start re-downloading Batch-relinking 9 torrents, one setLocation call silently failed (status ignored) while the script declared success and moved on. Its recheck then ran against the original (now-empty) path, found 0% match, and qBittorrent started re-downloading the whole torrent from scratch into its incomplete-files staging area. No lasting harm (separate path from the real hardlinked copy, cleaned up), but caught only by watching qBittorrent directly, not by anything the script reported. Fixes: every mutating call now goes through an api_call() helper that checks the HTTP status and aborts on failure; the torrent is stopped before any location/rename calls (qBittorrent 5.x renamed pause/resume to stop/start) and left stopped after recheck rather than auto-resuming, so a bad relink can never turn into an active download. setLocation's effect is also verified via a follow-up GET before proceeding to renameFile. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
187 lines
9.8 KiB
Markdown
187 lines
9.8 KiB
Markdown
---
|
|
name: sonarr
|
|
description: Use Sonarr to add TV shows and manually import existing raw-named folders into the Show Name (Year)/Season NN/... layout Jellyfin and Plex expect. Use when migrating the TV library, importing a newly-downloaded show, or otherwise interacting with this repo's Sonarr instance.
|
|
---
|
|
|
|
# Sonarr library migration/import
|
|
|
|
This repo's Sonarr manages TV show organization for both Jellyfin and Plex
|
|
(they share the same `nas_media` library at `/data/video/tv`). The
|
|
`claude-homelab` plugin's Sonarr skill (if installed) only covers
|
|
search/add/remove — it has no manual-import support. This skill's `sonarr.sh`
|
|
covers that gap: matching an existing raw-named folder to the right
|
|
show/episodes and importing it into place.
|
|
|
|
Always use `./sonarr.sh` (repo root) for this — never hand-roll the Sonarr API
|
|
calls, the folder-naming token gotcha below has already bitten this workflow
|
|
once.
|
|
|
|
## Workflow: migrate one existing show
|
|
|
|
1. **Find the TVDB match**: `./sonarr.sh lookup "<show name>"` — prints
|
|
candidates as `tvdb:<id> Title (Year) status`. Pick the right one (check
|
|
year/status against what's actually in the folder).
|
|
2. **Add it**: `./sonarr.sh add tvdb:<id>` — adds unmonitored, no search, root
|
|
folder defaults to `/data/video/tv`. Skip if already in
|
|
`./sonarr.sh list`.
|
|
3. **Preview the import**: `./sonarr.sh scan "<raw folder path>"` — shows how
|
|
each file will map to season/episode, and flags anything Sonarr couldn't
|
|
parse (`!! <rejection reason>`). **Read this before importing** — don't
|
|
skip straight to step 4.
|
|
4. **Apply it**: `./sonarr.sh import "<raw folder path>"` — re-scans and
|
|
imports only if every file matched cleanly (refuses and tells you to check
|
|
`scan` output otherwise, rather than partially importing). Files are
|
|
hardlinked (not copied) into
|
|
`/data/video/tv/Show Name (Year)/Season NN/Show Name - SxxExx - Title Quality.ext`
|
|
— no extra disk usage, original folder is left behind but empty.
|
|
5. **Clean up the empty source folder**:
|
|
`docker exec jellyfin rmdir "<raw folder path>"` (any container with the
|
|
`nas_media` mount works).
|
|
6. **Re-point qBittorrent at the new location** — not automatic, see below.
|
|
Skip only if the show was never actually downloaded via qBittorrent (rare).
|
|
7. **Verify Plex picks it up correctly** — also not automatic, see below.
|
|
|
|
## Re-pointing qBittorrent after an import
|
|
|
|
Sonarr's hardlink-import removes the file from its original torrent-named
|
|
folder (only the new hardlinked copy remains) — qBittorrent's own record of
|
|
that download doesn't know this happened and silently keeps pointing at the
|
|
now-empty original path. It won't show an error immediately (a `stalledUP`
|
|
torrent doesn't re-read its files until asked to), but the next recheck or
|
|
peer request will flip it to a `missingFiles` error state.
|
|
|
|
Fix it with `./qbt-relink.sh <torrent-hash> <new-season-folder>` (repo root):
|
|
|
|
```bash
|
|
./qbt-relink.sh 8601b9b9a7164ff5038aa1f8e678e3e708eea845 "/data/video/tv/Andor (2022)/Season 02"
|
|
```
|
|
|
|
Find the hash first:
|
|
```bash
|
|
source ~/.claude-homelab/.env
|
|
bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh list \
|
|
| python3 -c "import sys,json; [print(t['hash'], t['name']) for t in json.load(sys.stdin)]" | grep -i "<show name>"
|
|
```
|
|
|
|
It **stops the torrent first** (qBittorrent 5.x renamed pause/resume to
|
|
stop/start), sets its location to the season folder — verifying the change
|
|
actually took effect before continuing, not just trusting a 200 response —
|
|
renames each file to match Sonarr's output (paired by sorted order — always
|
|
check the printed preview makes sense), then triggers a recheck, and leaves
|
|
the torrent **stopped** afterward rather than auto-resuming. Since it's the
|
|
same underlying data (hardlink, same inode), the hash check passes and the
|
|
torrent is ready to seed normally again once you manually start it — no
|
|
re-download needed. **The recheck reads the whole file over NFS and is
|
|
slow** (minutes per multi-GB file) — kick off several in parallel rather than
|
|
waiting on each one serially, and poll with:
|
|
|
|
```bash
|
|
bash ~/.claude/plugins/cache/claude-homelab/homelab-core/*/skills/qbittorrent/scripts/qbit-api.sh info <hash>
|
|
```
|
|
|
|
Once a torrent shows 100% progress / `stoppedUP` (not `stoppedDL` — that
|
|
means the recheck found missing or mismatched pieces), start it again from
|
|
the WebUI. **Never assume a relink succeeded without checking** — see the
|
|
incident below.
|
|
|
|
For a multi-season show downloaded as separate per-season torrents (e.g.
|
|
Babylon 5), run `qbt-relink.sh` once per season/torrent, pointing each at its
|
|
own `Season NN` folder — don't try to relink multiple torrents to one shared
|
|
show-root folder, since the tool matches file counts 1:1 between the torrent
|
|
and the destination folder.
|
|
|
|
### Incident: an unchecked `setLocation` call let a torrent start re-downloading
|
|
|
|
The first version of `qbt-relink.sh` fired `setLocation`/`renameFile`/
|
|
`recheck` with `curl -s ... > /dev/null`, discarding the HTTP status. Running
|
|
it across 9 torrents in a batch, one `setLocation` call silently failed (the
|
|
torrent still hadn't been re-pointed) while the script declared success
|
|
anyway. Its recheck then ran against the original path — already empty,
|
|
since Sonarr's import had removed the file — found 0% of pieces present, and
|
|
qBittorrent started **re-downloading the entire torrent from scratch** into
|
|
qBittorrent's default incomplete-files staging path
|
|
(`/data/torrents/incomplete/...`), racking up several minutes of real
|
|
download traffic before it was caught and stopped.
|
|
|
|
No actual harm resulted — the fresh download went to a separate staging path
|
|
and never touched the Sonarr-organized hardlinked copy (confirmed by
|
|
checking file counts and Plex/disk state directly), and the stray partial
|
|
files were deleted — but it could have gone worse (wasted bandwidth on a
|
|
private tracker, or worse, if the download target had somehow overlapped
|
|
with the real file). Root cause: no HTTP status checking, and the torrent
|
|
was never stopped before being touched. Both are now fixed in the script
|
|
(the `api_call` helper checks every response; the torrent is stopped before
|
|
any location/rename calls and stays stopped after recheck) — but the lesson
|
|
generalizes: **when scripting a batch of mutating API calls, verify each one
|
|
actually took effect before moving to the next, especially anything that
|
|
could make a client start writing data on its own.**
|
|
|
|
Credentials (`QBITTORRENT_URL`/`USERNAME`/`PASSWORD`) live in
|
|
`~/.claude-homelab/.env` (the claude-homelab plugin's credential file), not
|
|
this repo's `.credentials` — `qbt-relink.sh` sources that file directly.
|
|
|
|
## Verifying the Plex side (do this every time, not just once)
|
|
|
|
Moving a file changes its path. Plex's database still points at the *old*
|
|
path until it rescans — it does not watch the filesystem live here. After
|
|
every import:
|
|
|
|
```bash
|
|
TOKEN=$(docker exec plex grep -o 'PlexOnlineToken="[^"]*"' \
|
|
"/config/Library/Application Support/Plex Media Server/Preferences.xml" | cut -d'"' -f2)
|
|
curl -s "http://localhost:32400/library/sections/2/refresh?X-Plex-Token=$TOKEN"
|
|
```
|
|
|
|
(Section `2` is "TV Shows" in this Plex instance — confirm with
|
|
`curl -s "http://localhost:32400/library/sections?X-Plex-Token=$TOKEN"` if
|
|
unsure.) Then spot-check that the show's episodes still show the correct
|
|
`viewCount`/watch state pointing at the new file path — Plex re-matches by
|
|
its own metadata agent (title/GUID), not by the old path, so watched status
|
|
normally survives a move, but confirm it rather than assuming, especially for
|
|
messier shows (duplicate releases, specials, date-based naming).
|
|
|
|
## Known gotcha: `{Year}` is not a valid Sonarr token
|
|
|
|
The series folder format must use the combined token `{Series TitleYear}`
|
|
(produces `Title (Year)` as one unit) — `{Series Title} ({Year})` silently
|
|
produces `Title ()` with no year, because Sonarr has no standalone `{Year}`
|
|
token for series-level naming. This bit the first two shows migrated
|
|
(`Andor`, `3 Body Problem`) before being caught — check
|
|
`docker exec sonarr curl -s http://localhost:8989/api/v3/config/naming/examples -H "X-Api-Key: $SONARR_API_KEY"`
|
|
(or `curl "$SONARR_URL/api/v3/config/naming/examples" -H "X-Api-Key: $SONARR_API_KEY"`
|
|
from the host) any time naming config changes — `seriesFolderExample` should
|
|
show a real year, not `()`.
|
|
|
|
## Edge cases that will need manual handling (not automatable via scan/import)
|
|
|
|
- **Date-based shows** (e.g. Jeopardy) — no season/episode numbers in
|
|
filenames. Set the show's Series Type to "Daily" in the Sonarr UI before
|
|
scanning; `scan` will otherwise reject every file.
|
|
- **Specials mixed into a season folder** (e.g. Lower Decks `S00E501`-style
|
|
files) — Sonarr should route these to `Season 00` correctly; verify rather
|
|
than assume.
|
|
- **Duplicate episodes from two releases** (e.g. Poker Face) — decide which
|
|
release to keep before importing; `scan` will show both, `import` will
|
|
refuse until resolved since it won't guess.
|
|
- **Combined/ambiguous episode files** (e.g. `e01-02.` with no season number)
|
|
— `scan` will likely reject these; use the Sonarr web UI's Manual Import
|
|
screen instead, which allows manually assigning an episode range per file
|
|
(the CLI script only handles clean auto-matches by design, so it can't
|
|
silently mis-import something ambiguous).
|
|
|
|
## Other commands
|
|
|
|
- `./sonarr.sh list` — series currently in Sonarr
|
|
- `./sonarr.sh rootfolders` — root folders + how many unmapped (not-yet-added)
|
|
folders each has
|
|
- `./sonarr.sh queue` — current download queue
|
|
- `./sonarr.sh test-client` — tests configured download client connections
|
|
(currently just qBittorrent_vpn, category `tv-sonarr`)
|
|
|
|
## Credentials
|
|
|
|
`SONARR_API_KEY` / `SONARR_URL` live in this repo's `.credentials` (source it
|
|
before running `sonarr.sh` manually outside the script — the script sources
|
|
it itself). Get a fresh key from Sonarr UI → Settings → General → Security,
|
|
or `docker exec sonarr grep -o 'ApiKey>[^<]*' /config/config.xml`.
|