api_post/api_put passed the JSON payload directly as a curl -d command-line argument, which hit the OS ARG_MAX limit importing Jeopardy's 1269-file library (a large date-based show is exactly the case where a batch import payload gets big). Now written to a temp file and passed via curl's -d @file instead. Claude-Session: https://claude.ai/code/session_01HZQK6jHmdTpFjFZM8FUnqA
389 lines
13 KiB
Bash
Executable File
389 lines
13 KiB
Bash
Executable File
#!/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" "$@"
|
|
}
|
|
|
|
# Payload goes through a temp file (curl -d @file), not as a command-line
|
|
# argument - a large library (e.g. Jeopardy's 1269-file import payload) can
|
|
# exceed the OS argument-length limit and fail with "Argument list too long"
|
|
# if passed directly via -d "$2".
|
|
api_post() {
|
|
local payload_file
|
|
payload_file=$(mktemp)
|
|
printf '%s' "$2" > "$payload_file"
|
|
curl -s -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
|
-d "@$payload_file" "$API/$1"
|
|
rm -f "$payload_file"
|
|
}
|
|
|
|
api_put() {
|
|
local payload_file
|
|
payload_file=$(mktemp)
|
|
printf '%s' "$2" > "$payload_file"
|
|
curl -s -X PUT -H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
|
-d "@$payload_file" "$API/$1"
|
|
rm -f "$payload_file"
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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_monitor() {
|
|
local series_id="${1:-}"
|
|
local do_search="${2:-}"
|
|
if [[ -z "$series_id" ]]; then
|
|
echo "Usage: $0 monitor <seriesId> [--search]" >&2
|
|
echo " Flips a show from unmonitored (migration-only) to monitored - Sonarr will" >&2
|
|
echo " pick up new episodes via the tv-sonarr qBittorrent category going forward." >&2
|
|
echo " --search also triggers an immediate search for any missing episodes." >&2
|
|
exit 1
|
|
fi
|
|
|
|
local series_json
|
|
series_json=$(api_get "series/$series_id")
|
|
local not_found
|
|
not_found=$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); print('yes' if 'title' not in d else 'no')" "$series_json")
|
|
if [[ "$not_found" == "yes" ]]; then
|
|
echo "Error: no series with id $series_id (see: ./sonarr.sh list)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local payload
|
|
payload=$(python3 -c "
|
|
import json, sys
|
|
s = json.loads(sys.argv[1])
|
|
s['monitored'] = True
|
|
# Also monitor all seasons - a series-level monitored flag alone won't pull
|
|
# individual season/episode monitoring along with it.
|
|
for season in s.get('seasons', []):
|
|
season['monitored'] = True
|
|
print(json.dumps(s))
|
|
" "$series_json")
|
|
|
|
local response
|
|
response=$(api_put "series/$series_id" "$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\"Monitoring: {d['title']} ({d['year']}) - new episodes will auto-import via the tv-sonarr download client category.\")
|
|
" "$response"
|
|
|
|
if [[ "$do_search" == "--search" ]]; then
|
|
api_post "command" "{\"name\": \"SeriesSearch\", \"seriesId\": $series_id}" > /dev/null
|
|
echo "Triggered a search for missing/upcoming episodes."
|
|
fi
|
|
}
|
|
|
|
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 ;;
|
|
monitor) cmd_monitor "${2:-}" "${3:-}" ;;
|
|
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 " monitor <seriesId> [--search] Switch a show to monitored (auto-import new"
|
|
echo " episodes via tv-sonarr going forward); --search"
|
|
echo " also searches now for anything missing"
|
|
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
|