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

16 KiB

name, description
name description
sonarr 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):

./qbt-relink.sh 8601b9b9a7164ff5038aa1f8e678e3e708eea845 "/data/video/tv/Andor (2022)/Season 02"

Find the hash first:

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 ~/.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:

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.