#!/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