Files
poprhythm a54f11ccbc Update sonarr skill doc: qbt-relink.sh re-enabled
Reflects the rewritten script's blocking single-pass model and the
hard rule against re-triggering stop/setLocation/recheck against the
same hash more than once. Marks Incident 3 as fixed and verified.

Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
2026-09-09 03:16:58 +00:00

18 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 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 "<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>"

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 <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

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:

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.