From 34a45cba76d4a7365a29f0735522d28e09e9de35 Mon Sep 17 00:00:00 2001 From: poprhythm Date: Tue, 8 Sep 2026 02:38:48 +0000 Subject: [PATCH] Add sonarr.sh + skill for TV library manual-import migration claude-homelab's Sonarr skill only covers search/add/remove, not the manual-import workflow the Jellyfin migration depends on. Wraps lookup/add/scan/import into a script matching this repo's portainer.sh conventions, replacing repetitive raw curl calls. Also documents a naming-token gotcha hit while migrating the first two pilot shows: Sonarr's series folder format needs the combined {Series TitleYear} token, not {Series Title} ({Year}) - the latter silently drops the year. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA --- .claude/skills/sonarr/SKILL.md | 105 +++++++++++ sonarr.sh | 323 +++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 .claude/skills/sonarr/SKILL.md create mode 100755 sonarr.sh diff --git a/.claude/skills/sonarr/SKILL.md b/.claude/skills/sonarr/SKILL.md new file mode 100644 index 0000000..56b8aa1 --- /dev/null +++ b/.claude/skills/sonarr/SKILL.md @@ -0,0 +1,105 @@ +--- +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 + +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. **Verify Plex picks it up correctly** — this is not automatic, see below. + +## 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`. diff --git a/sonarr.sh b/sonarr.sh new file mode 100755 index 0000000..c9ad783 --- /dev/null +++ b/sonarr.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# Sonarr management script - focused on the library-migration workflow +# (search/add existing shows, scan a raw folder, and manually import it into +# the Show Name (Year)/Season NN/... layout Jellyfin/Plex expect). +# +# Usage: +# ./sonarr.sh lookup +# ./sonarr.sh add [rootFolderPath] +# ./sonarr.sh list +# ./sonarr.sh rootfolders +# ./sonarr.sh scan +# ./sonarr.sh import +# ./sonarr.sh queue +# ./sonarr.sh test-client + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CREDENTIALS_FILE="$SCRIPT_DIR/.credentials" + +if [[ ! -f "$CREDENTIALS_FILE" ]]; then + echo "Error: credentials file not found at $CREDENTIALS_FILE" >&2 + exit 1 +fi + +# shellcheck source=.credentials +source "$CREDENTIALS_FILE" + +API="$SONARR_URL/api/v3" +AUTH_HEADER="X-Api-Key: $SONARR_API_KEY" +DEFAULT_ROOT="/data/video/tv" +DEFAULT_QUALITY_PROFILE_ID=1 + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +api_get() { + curl -s -H "$AUTH_HEADER" "$API/$1" +} + +api_get_query() { + local path="$1" + shift + curl -s -G -H "$AUTH_HEADER" "$API/$path" "$@" +} + +api_post() { + curl -s -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \ + -d "$2" "$API/$1" +} + +api_put() { + curl -s -X PUT -H "$AUTH_HEADER" -H "Content-Type: application/json" \ + -d "$2" "$API/$1" +} + +# -------------------------------------------------------------------------- +# Commands +# -------------------------------------------------------------------------- + +cmd_lookup() { + local term="${1:-}" + if [[ -z "$term" ]]; then + echo "Usage: $0 lookup " >&2 + exit 1 + fi + api_get_query "series/lookup" --data-urlencode "term=$term" | python3 -c " +import json, sys +results = json.load(sys.stdin) +if not results: + print('No matches found.') + sys.exit(0) +for s in results[:10]: + existing = ' [already in Sonarr]' if s.get('id') else '' + print(f\"tvdb:{s.get('tvdbId')}\t{s['title']} ({s.get('year')})\t{s.get('status')}{existing}\") +" +} + +cmd_add() { + local tvdb_id="${1:-}" + local root="${2:-$DEFAULT_ROOT}" + if [[ -z "$tvdb_id" ]]; then + echo "Usage: $0 add [rootFolderPath]" >&2 + exit 1 + fi + tvdb_id="${tvdb_id#tvdb:}" # accept either "411959" or "tvdb:411959" (lookup's output format) + + local lookup_json + lookup_json=$(api_get_query "series/lookup" --data-urlencode "term=tvdb:$tvdb_id") + + local payload + payload=$(python3 -c " +import json, sys +results = json.loads(sys.argv[1]) +if not results: + print('Error: no series found for that TVDB id', file=sys.stderr) + sys.exit(1) +s = results[0] +s['rootFolderPath'] = sys.argv[2] +s['qualityProfileId'] = $DEFAULT_QUALITY_PROFILE_ID +s['seasonFolder'] = True +s['monitored'] = False +s['addOptions'] = { + 'monitor': 'none', + 'searchForMissingEpisodes': False, + 'searchForCutoffUnmetEpisodes': False, +} +print(json.dumps(s)) +" "$lookup_json" "$root") + + local response + response=$(api_post "series" "$payload") + + python3 -c " +import json, sys +d = json.loads(sys.argv[1]) +if 'message' in d and 'id' not in d: + print('Error:', d.get('message')) + sys.exit(1) +print(f\"Added: {d['title']} ({d['year']}) -> {d['path']} [id={d['id']}]\") +" "$response" +} + +cmd_list() { + api_get "series" | python3 -c " +import json, sys +for s in sorted(json.load(sys.stdin), key=lambda x: x['sortTitle']): + print(f\"{s['id']}\t{s['title']} ({s.get('year')})\t{s['path']}\") +" +} + +cmd_rootfolders() { + api_get "rootfolder" | python3 -c " +import json, sys +for r in json.load(sys.stdin): + print(f\"{r['path']}\tfree: {r['freeSpace']/1e9:.1f} GB\tunmapped folders: {len(r.get('unmappedFolders', []))}\") +" +} + +cmd_scan() { + local folder="${1:-}" + if [[ -z "$folder" ]]; then + echo "Usage: $0 scan " >&2 + exit 1 + fi + api_get_query "manualimport" --data-urlencode "folder=$folder" --data-urlencode "filterExistingFiles=true" \ + > /tmp/sonarr_scan_result.json + python3 -c " +import json +d = json.load(open('/tmp/sonarr_scan_result.json')) +if isinstance(d, dict) and 'message' in d: + print('Error:', d['message']) + raise SystemExit(1) +if not d: + print('No files found (or all already imported).') + raise SystemExit(0) +for f in d: + series = f.get('series') or {} + eps = f.get('episodes') or [] + epstr = ', '.join(f\"S{e['seasonNumber']:02d}E{e['episodeNumber']:02d}\" for e in eps) or '(no episode match)' + rejections = [r['reason'] for r in f.get('rejections', [])] + flag = ' !! ' + '; '.join(rejections) if rejections else '' + print(f\"{f['name']}\") + print(f\" -> {series.get('title','?')} {epstr}{flag}\") +print() +print(f'{len(d)} file(s). Full detail saved to /tmp/sonarr_scan_result.json') +print('If this all looks right, run: ./sonarr.sh import \"$folder\"') +" +} + +cmd_import() { + local folder="${1:-}" + if [[ -z "$folder" ]]; then + echo "Usage: $0 import " >&2 + exit 1 + fi + + echo "Scanning $folder..." + api_get_query "manualimport" --data-urlencode "folder=$folder" --data-urlencode "filterExistingFiles=true" \ + > /tmp/sonarr_import_scan.json + + local has_rejections + has_rejections=$(python3 -c " +import json +d = json.load(open('/tmp/sonarr_import_scan.json')) +if isinstance(d, dict) and 'message' in d: + print('ERROR:' + d['message']) + raise SystemExit(0) +if not d: + print('EMPTY') + raise SystemExit(0) +bad = [f for f in d if f.get('rejections') or not f.get('episodes') or not f.get('series')] +if bad: + print('REJECTED') + for f in bad: + reasons = '; '.join(r['reason'] for r in f.get('rejections', [])) or 'no series/episode match' + print(f\" - {f['name']}: {reasons}\", file=__import__('sys').stderr) +else: + print('OK') +") + + case "$has_rejections" in + OK) : ;; + EMPTY) + echo "Nothing to import (folder empty or already imported)." + return 0 + ;; + REJECTED) + echo "Refusing to auto-import: some files have no clean match or were rejected." >&2 + echo "Run './sonarr.sh scan \"$folder\"' to see details, resolve manually in the Sonarr UI's Manual Import screen instead." >&2 + exit 1 + ;; + ERROR:*) + echo "Error: ${has_rejections#ERROR:}" >&2 + exit 1 + ;; + esac + + local payload + payload=$(python3 -c " +import json +d = json.load(open('/tmp/sonarr_import_scan.json')) +files = [] +for f in d: + files.append({ + 'path': f['path'], + 'seriesId': f['series']['id'], + 'episodeIds': [e['id'] for e in f['episodes']], + 'quality': f['quality'], + 'languages': f['languages'], + 'releaseGroup': f.get('releaseGroup'), + 'indexerFlags': f.get('indexerFlags', 0), + 'downloadId': f.get('downloadId'), + }) +print(json.dumps({'name': 'ManualImport', 'files': files, 'importMode': 'auto'})) +") + + echo "Importing $(python3 -c "import json;print(len(json.load(open('/tmp/sonarr_import_scan.json'))))") file(s)..." + local response + response=$(api_post "command" "$payload") + local command_id + command_id=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['id'])" "$response") + + # Poll for completion + for _ in $(seq 1 30); do + sleep 1 + local status + status=$(api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['status'])") + if [[ "$status" == "completed" ]]; then + api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print('Done:', d.get('message', 'completed'))" + return 0 + elif [[ "$status" == "failed" ]]; then + api_get "command/$command_id" | python3 -c "import json,sys; d=json.load(sys.stdin); print('Failed:', d.get('message', 'unknown error'))" + exit 1 + fi + done + echo "Still running after 30s - check Sonarr Activity tab for status." +} + +cmd_queue() { + api_get "queue" | python3 -c " +import json, sys +d = json.load(sys.stdin) +records = d.get('records', []) +if not records: + print('Queue is empty.') +for r in records: + print(f\"{r.get('series',{}).get('title','?')} {r.get('episode',{}).get('title','?')}\t{r.get('status')}\t{r.get('trackedDownloadStatus','')}\") +" +} + +cmd_test_client() { + api_get "downloadclient" | python3 -c " +import json, sys +clients = json.load(sys.stdin) +for c in clients: + print(f\"Testing {c['name']} (id={c['id']})...\") +" + local ids + ids=$(api_get "downloadclient" | python3 -c "import json,sys; [print(c['id']) for c in json.load(sys.stdin)]") + for id in $ids; do + local dc_json + dc_json=$(api_get "downloadclient/$id") + local result + result=$(curl -s -X POST "$API/downloadclient/test?forceTest=true" -H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$dc_json") + if [[ "$result" == "{}" ]]; then + echo " OK" + else + echo " FAILED: $result" + fi + done +} + +# -------------------------------------------------------------------------- +# Dispatch +# -------------------------------------------------------------------------- + +command="${1:-}" +case "$command" in + lookup) cmd_lookup "${2:-}" ;; + add) cmd_add "${2:-}" "${3:-}" ;; + list) cmd_list ;; + rootfolders) cmd_rootfolders ;; + scan) cmd_scan "${2:-}" ;; + import) cmd_import "${2:-}" ;; + queue) cmd_queue ;; + test-client) cmd_test_client ;; + *) + echo "Usage: $0 [args]" + echo "" + echo "Commands:" + echo " lookup Search TVDB for a show" + echo " add [root] Add show to Sonarr (unmonitored, no search)" + echo " list List series already in Sonarr" + echo " rootfolders List root folders + unmapped folder counts" + echo " scan Preview manual-import mapping for a raw folder" + echo " import Scan + apply import (aborts if any file is unmatched)" + echo " queue Show current download queue" + echo " test-client Test all configured download clients" + exit 1 + ;; +esac