--- 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` was paused after a real data-loss incident (2026-09-08, Babylon 5 S03/S04, 44 episodes — see "Incident 3" below) and has since been rewritten with a real safety model and re-verified end-to-end against a live 73GB torrent.** Root cause: repeated manual re-triggering (stop/setLocation/ recheck called more than once across separate debugging attempts on the same hash) raced against qBittorrent's own automatic incomplete-file management. The fix isn't a patch on top of the old script - it's a different operating discipline: **the whole relink is one blocking pass with no manual re-intervention, ever**. See "Current safety model" below before touching this again if you're tempted to run raw curl calls against a hash mid-flow - that impulse is exactly what caused the incident. 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 ""` — prints candidates as `tvdb: Title (Year) status`. Pick the right one (check year/status against what's actually in the folder). 2. **Add it**: `./sonarr.sh add tvdb:` — 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 ""` — shows how each file will map to season/episode, and flags anything Sonarr couldn't parse (`!! `). **Read this before importing** — don't skip straight to step 4. 4. **Apply it**: `./sonarr.sh import ""` — 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 ""` (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 ` (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 "" ``` ## Current safety model (post-rewrite) The script runs the **entire operation as one blocking pass** — it does not return control partway through for you to poll and come back to. It takes minutes per multi-GB file (recheck reads the whole file over NFS), so expect it to sit there running; that's normal, not stuck. Run it via a background shell / `run_in_background` and wait for it to actually exit rather than interrupting it. **The hard rule this exists to enforce: never call `stop`/`setLocation`/ `recheck` against the same torrent hash a second time while a previous invocation (of this script, or a raw curl call) might still be settling.** That repeated-intervention pattern — re-triggering by hand while investigating why something looked stuck — is what caused Incident 3's real data loss, even though every individual safety check along the way reported success. If a run times out (40 min) or fails partway, do not react by re-running raw curl commands against that hash — either re-run this exact script invocation (it's idempotent: it detects an already-correctly-relinked torrent by checking that qBittorrent's tracked filenames actually match the destination, not just progress, and does nothing further) or stop and investigate read-only first (`torrents/info`, `torrents/files`, the `/api/v2/log/main` log) before taking any write action. What one run does, in order: stop the torrent → set its location *and* download-path override to the season folder, each verified with a short poll (both are asynchronous — a single immediate check can read stale data) → pair and rename each file to match Sonarr's output (by parsed `SxxEyy` episode number, not sort order — see below) → trigger exactly one recheck → block, polling every 15s, until it leaves a `checking*` state (up to 40 min) → compare the destination folder against a filesystem manifest taken *before* any of this started → report pass/fail. The torrent is left **stopped** regardless of outcome — start it yourself from the WebUI once you're satisfied, never automatically. The pre/post filesystem manifest comparison is the real safety net — it does not trust qBittorrent's self-reported state at all for the final verdict, since Incident 3 demonstrated that state can look fine while real files are gone. If the script reports a mismatch, it exits without attempting any further remediation; investigate by hand from that known-bad state. 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. Kick off separate torrents' relinks as separate background invocations if you want them running concurrently, but never two invocations against the *same* hash at once. **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 ``. 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 **Status: fixed and re-verified (2026-09-09)** — see "Current safety model" above. Root cause turned out to be repeated manual re-triggering (multiple separate stop/setLocation/recheck calls against the same hash while debugging), not a flaw in any single check. The rewritten script enforces a single blocking pass with no re-intervention, and was re-verified end-to-end against a live 73GB torrent, including catching a real stale-state bug in its own idempotency check during that testing (see below). 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`.