in qbt-relink.sh Second live incident in the same batch: setLocation genuinely succeeded and save_path updated correctly, but content_path (what recheck actually reads) stayed pointed at qBittorrent's incomplete staging path via a leftover per-torrent download_path override from an earlier failed attempt. setLocation doesn't clear that override. Fixed by also calling torrents/setDownloadPath (note: takes "id", not "hashes") and verifying content_path directly before proceeding. Also replaced alphabetical-sort file pairing with SxxEyy-parsed matching, since sort order silently breaks on non-zero-padded episode numbers (E9 sorts after E10) - a real risk across the ~320 remaining folders with inconsistent naming conventions. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
12 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
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>"
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: 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.