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
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
- Find the TVDB match:
./sonarr.sh lookup "<show name>"— prints candidates astvdb:<id> Title (Year) status. Pick the right one (check year/status against what's actually in the folder). - 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. - 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. - Apply it:
./sonarr.sh import "<raw folder path>"— re-scans and imports only if every file matched cleanly (refuses and tells you to checkscanoutput 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. - Clean up the empty source folder:
docker exec jellyfin rmdir "<raw folder path>"(any container with thenas_mediamount works). - Re-point qBittorrent at the new location — not automatic, see below. Skip only if the show was never actually downloaded via qBittorrent (rare).
- 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;
scanwill otherwise reject every file. - Specials mixed into a season folder (e.g. Lower Decks
S00E501-style files) — Sonarr should route these toSeason 00correctly; verify rather than assume. - Duplicate episodes from two releases (e.g. Poker Face) — decide which
release to keep before importing;
scanwill show both,importwill refuse until resolved since it won't guess. - Combined/ambiguous episode files (e.g.
e01-02.with no season number) —scanwill 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, categorytv-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.