Files
docker-infrastructure/.claude/skills/sonarr/SKILL.md
T
poprhythm 49e47a32bf Document data-loss incident, pause qbt-relink.sh
Babylon 5 S03/S04 (44 episodes) were destroyed during a relink
attempt despite every safety check the script performs (HTTP status,
save_path, content_path) passing. qBittorrent's own automatic
incomplete-file management took further unrequested action after
the script's calls succeeded - moving/truncating files at a
location it still considered incomplete - which none of the
script's checks could see coming since they only verify immediately
after its own calls.

No backup existed. Files could not be found anywhere on /data after
a full search. Marking qbt-relink.sh unsafe until the interaction
with qBittorrent's temp_path_enabled behavior is understood well
enough to prevent this.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-08 10:13:07 +00:00

286 lines
16 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
**⚠️ `qbt-relink.sh` is currently UNSAFE — do not use it until this warning is
removed.** It caused real, apparently unrecoverable data loss (Babylon 5
seasons 3 and 4, 44 episodes) on 2026-09-08: qBittorrent's own automatic
incomplete-file management fought the script's manual `setLocation` calls
across repeated resume/stop cycles and physically moved/destroyed the real
files, even after every safety check the script performs (stopped state,
confirmed `save_path`, confirmed `content_path`) reported success. See
"Incident 3" below for the full detail. **The Sonarr import workflow itself
(`sonarr.sh scan`/`import`) is unaffected and still safe** — it's specifically
the qBittorrent-side relink step that's paused. Until this is fixed, leave
qBittorrent's stale torrent entries alone after a Sonarr import rather than
touching them — a `missingFiles` error in the qBittorrent UI is a much
smaller problem than what happened here.
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 *and* download-path override to the season
folder — verifying both actually took effect before continuing, not just
trusting a 200 response — pairs and renames each file to match Sonarr's
output (by parsed `SxxEyy` episode number, not sort order — see below), 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, and this qBittorrent
instance seems to only actively process one or two full-file rechecks at a
time — others sit at `stoppedDL`/0% "queued" looking identical to a real
failure until their turn comes) — 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.
**Known limitation**: if Sonarr split a single torrent's files across two
destination folders (e.g. a season pack that included specials, which Sonarr
routed to a separate `Specials`/`Season 00` folder), `qbt-relink.sh` can't
handle that in one call — it only knows about one `<new-folder>`. Check the
file count first (`torrents/files` count vs. `ls` count in the season
folder); if they don't match because of a specials split, this needs doing
by hand (or extending the tool) rather than forcing it.
### Incident 2: a stale `download_path` override kept recheck pointed at the wrong place entirely
Even after incident 1's fix (checked HTTP status, confirmed `save_path`
via a follow-up GET), three of the nine torrents in the same batch
(Babylon 5 S03/S04/S05) still failed — `setLocation` genuinely succeeded and
`save_path` genuinely updated, but their **`content_path`** (what qBittorrent
actually reads during a recheck) stayed pointed at
`/data/torrents/incomplete/video/tv`, qBittorrent's global incomplete-files
staging path (`temp_path`, from `app/preferences`). Cause: earlier in the
same incident, these three torrents had briefly been flagged as needing to
download again (a race between the bad first relink attempt and this
corrective one — see the qBittorrent log via `/api/v2/log/main` for
`"Torrent move canceled"` / duplicate `"Start moving torrent"` entries if
this happens again), which set a **per-torrent `download_path` override**
that `setLocation` does not clear. `content_path` silently followed that
stale override instead of `save_path` — so the recheck kept validating
against an empty folder and would have re-downloaded again, exactly like
incident 1, had it not been caught by watching state directly rather than
trusting the script's "Done" message.
Fix: `torrents/setDownloadPath` (note — takes `id`, not `hashes`, unlike
every other endpoint used here) clears the override, and `qbt-relink.sh` now
calls it and verifies `content_path` (not just `save_path`) actually landed
under the target folder before proceeding to rename/recheck. **If a torrent
ever seems stuck at `stoppedDL`/0% far longer than others in the same batch,
or its `state` is `checkingDL` instead of `checkingUP`**, check
`content_path` vs `save_path` directly — a mismatch means the recheck is
running against the wrong location and will "succeed" at finding nothing.
### Incident 3: qBittorrent's own automatic file management destroyed real data despite every check passing — data loss, `qbt-relink.sh` is now paused
2026-09-08, same overall session, later batch. Even with incidents 1 and 2's
fixes in place (`api_call` status checking, torrent stopped first,
`save_path` *and* `content_path` both confirmed correct before proceeding),
Babylon 5 S03 and S04 lost their actual files. The qBittorrent log
(`/api/v2/log/main`) tells the story: after `setLocation` moved them to
`Season 03`/`Season 04` (logged success), qBittorrent's *own* internal logic
immediately enqueued and executed a further move of those same files **back**
to `/data/torrents/incomplete/video/tv` on its own — nothing in the script
requested this. A later resume cycle moved them back to `Season 03`/`04`
again, but then repeated automatic resume/stop cycles followed (visible as
alternating `"Torrent resumed"`/`"Torrent stopped"` log lines with no
corresponding script action), and after those, both `Season 03` and
`Season 04` folders were **completely empty** — confirmed via `ls`/`stat`
from two separate containers, and a full search of `/data` found the files
nowhere. 44 episodes, gone. No backup existed (the user had explicitly
decided to proceed without one earlier in this project).
Best-guess mechanism: qBittorrent's `temp_path_enabled` incomplete-file
management re-evaluates on every resume, independent of manual
`setLocation`/`setDownloadPath` calls, and can decide to relocate — or,
worse, truncate/overwrite in place expecting fresh downloaded data — files
at a location it still considers "incomplete" (i.e. never successfully
verified as 100% via a *clean* recheck). Since these torrents' recheck kept
getting interrupted/retriggered across multiple attempts (network/NFS
slowness caused several manual re-triggers in this session), the torrent
seems to have never reached a stable "verified complete" state internally,
leaving it perpetually eligible for this automatic (and here, destructive)
relocation — regardless of what the script's own state checks reported.
**This means the safety checks this script relies on (`save_path`,
`content_path`, HTTP status) are necessary but not sufficient** — they
confirm the *script's* requests succeeded, but qBittorrent can still take
further unrequested action afterward that undoes or destroys the result,
and none of that is visible to a script that only checks immediately after
its own calls. `qbt-relink.sh` is paused (see the warning at the top of this
file) until this is understood well enough to prevent it — likely candidates
for a real fix: disabling `temp_path_enabled` globally before relinking
(and confirming no other torrent depends on it) as a global preference, an
approach that fully separates the qBittorrent-tracked download from the
Sonarr-organized library so they never share a path, or verifying via a
polling loop that a recheck reaches a genuinely stable end state (not just
"currently reports 100% right now") before considering a torrent safe.
### Incident 1: 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`.