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
This commit is contained in:
@@ -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 "<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. **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`.
|
||||
@@ -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 <term>
|
||||
# ./sonarr.sh add <tvdbId> [rootFolderPath]
|
||||
# ./sonarr.sh list
|
||||
# ./sonarr.sh rootfolders
|
||||
# ./sonarr.sh scan <folder>
|
||||
# ./sonarr.sh import <folder>
|
||||
# ./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 <term>" >&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 <tvdbId> [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 <folder>" >&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 <folder>" >&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 <command> [args]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " lookup <term> Search TVDB for a show"
|
||||
echo " add <tvdbId> [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 <folder> Preview manual-import mapping for a raw folder"
|
||||
echo " import <folder> 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
|
||||
Reference in New Issue
Block a user