The scan's own header calls reverse-anime leaks "genuine judgment calls" and reports them as a count. Seventeen of them concealed two live-action crime dramas filed under anime for as long as nobody read the list. The scan now persists that bucket as its own verdict, and the sweep asks the model to sort only those eleven — not the eleven hundred it already classifies correctly. Files findings for misfiled and uncertain only; donghua and anime-adjacent are answers, not problems, and filing them would rebuild the undifferentiated list this exists to break up. Moves nothing — --move stays a flag a human types.
558 lines
28 KiB
Bash
Executable File
558 lines
28 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ============================ Sonarr Content Classification Scan ==============================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Same problem as radarr_classification_scan.sh, TV side: Overseerr lets any user request a
|
|
# show into the wrong root folder (kids shows added to the general TV share, anime added to
|
|
# Kids_Tv_Shows, etc.). This script reads Sonarr's tracked series list and classifies every
|
|
# series as anime / kids-only / regular using metadata signals alone (genre, certification,
|
|
# network, original language) — then reports where a series' computed classification
|
|
# disagrees with the root folder it's actually sitting in, in both directions:
|
|
#
|
|
# FORWARD — a series classified as anime/kids is sitting outside its dedicated root
|
|
# REVERSE — a series sitting inside the kids/anime root doesn't match that classification
|
|
#
|
|
# Report-only by default. Every rule below was validated against this library's real data
|
|
# before being adopted — see the companion comment block in master.conf above the curated
|
|
# lists. Pass --move to actually act (see MOVE MODE below) — nothing writes to Sonarr unless
|
|
# that flag is given.
|
|
#
|
|
# ==============================================================================================
|
|
# CLASSIFICATION RULES — DIFFERENT FIELD MODEL THAN RADARR, NOT A COPY-PASTE
|
|
# ==============================================================================================
|
|
#
|
|
# TV metadata (TheTVDB, via Sonarr) shapes these signals differently than movie metadata
|
|
# (TMDb, via Radarr) — every difference below was confirmed live, not assumed:
|
|
# - Sonarr has an explicit "Anime" genre tag; Radarr does not.
|
|
# - Sonarr uses a single "network" field (TheTVDB's broadcaster), not "studio".
|
|
# - Certification is on the US TV Parental Guidelines scale (TV-Y/TV-Y7/TV-G/TV-PG/
|
|
# TV-14/TV-MA), not the MPAA scale — the tiers do not mean the same thing at the same
|
|
# position (TV-G is "general audience", not "for children", unlike movie G).
|
|
#
|
|
# is_anime:
|
|
# genre "Anime" (corroborated by Japanese language OR a Japan network — the bare tag alone
|
|
# produced a real false positive: "Craig of the Creek", an all-American Cartoon Network
|
|
# show, carries an "Anime" genre tag on TheTVDB for no discernible reason)
|
|
# OR (genre Animation AND originalLanguage Japanese)
|
|
# OR network in SONARR_ANIME_NETWORKS
|
|
# Always wins over kids when both could apply — explicit priority, not a tiebreak.
|
|
#
|
|
# is_kids ("kids will end up watching this alone" — NOT "family show night"):
|
|
# not is_anime AND (
|
|
# genre "Children" (NOT "Family" — see below)
|
|
# OR certification in (TV-Y, TV-Y7) (NOT TV-G — see below)
|
|
# OR network in SONARR_KIDS_NETWORKS
|
|
# )
|
|
# "Family" genre and "TV-G" certification were both tested standalone and rejected —
|
|
# both catch general-audience live-action content the whole household watches together
|
|
# (I Love Lucy, The Brady Bunch, Full House, Homestead Rescue), not kids-only content.
|
|
# Blanket "Animation" genre was also tested and rejected — it's dominated on TV by adult
|
|
# animated sitcoms (Rick and Morty, BoJack Horseman, Family Guy, South Park), unlike the
|
|
# movie side where it's a usable (gated) signal.
|
|
#
|
|
# No junk-detection tier here (unlike Radarr) — TheTVDB's ratings/imdbId data is far
|
|
# sparser than TMDb's even for completely legitimate shows (confirmed live: "The Pussycat
|
|
# Dolls Present: The Search for the Next Doll", a real 2007 MTV show, has ratings.votes=0
|
|
# and imdbId=null) — the vote-count heuristic that works for Radarr would flag real content
|
|
# for removal here, so it's deliberately not reused. --remove-junk from the Radarr script has
|
|
# no Sonarr equivalent for the same reason.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# Report pass (always):
|
|
# check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
|
|
# → classify every series → report FORWARD and REVERSE disagreements → exit
|
|
#
|
|
# Move pass (--move only), described in detail below:
|
|
#
|
|
# Acts on FORWARD misplacements (classified anime/kids, sitting in the wrong root) and on
|
|
# REVERSE-KIDS leaks (adult certification sitting in the kids root — moved back to
|
|
# SONARR_GENERAL_ROOT). Does NOT act on REVERSE-ANIME leaks — those are genuine judgment
|
|
# calls, since deliberate style placements (Castlevania-type Western/Chinese animation
|
|
# grouped with anime by choice) legitimately live in the anime root without matching the
|
|
# anime signal.
|
|
#
|
|
# episodeFileCount is Sonarr's equivalent of Radarr's hasFile — a series can have 0 files
|
|
# (fully monitored, nothing downloaded) even while correctly classified. Those get their
|
|
# rootFolderPath/path corrected and an immediate SeriesSearch triggered rather than a file
|
|
# move (mirrors radarr_classification_scan.sh's handling of hasFile=false movies).
|
|
#
|
|
# One series at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
|
|
# episodeFileCount) instantly, but the physical move is a separate async MoveSeries command
|
|
# Sonarr drains one at a time internally — DB fields alone can report "moved" while the real
|
|
# files are still sitting at the old path behind other queued moves (confirmed live: "Full
|
|
# House" reported episodeFileCount:192 at the new path via API while the actual 75GB/192
|
|
# files hadn't moved yet). Each move polls its own MoveSeries command to "completed" before
|
|
# the DB-field check runs, so a batch can't compound the race the way a bare sleep-and-check did.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# Curated Lists, Not Bare Genre/Cert Matching — see master.conf comments for exclusions.
|
|
# Cache-First — arr_get_tracked_data() same as sonarr_cleanup.sh, single call regardless
|
|
# of library size. Refreshed after --move writes so no other script reads stale data.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Root Enforcement
|
|
# Required by the container interaction and state writes.
|
|
#
|
|
# Lock Acquisition
|
|
# acquire_lock prevents a scheduled run overlapping a manual --move.
|
|
#
|
|
# Host Detection
|
|
# detect_hosts() aliases SONARR_URL / SONARR_API_KEY / the root literals.
|
|
#
|
|
# curl + jq Dependency Check
|
|
# Fails fast if either is missing — every classification signal is parsed with jq,
|
|
# and a missing jq would evaluate each signal to empty and classify nothing.
|
|
#
|
|
# Report-Only Default
|
|
# Nothing is written to Sonarr without --move. The scan is safe to schedule and
|
|
# safe to run repeatedly while tuning the curated lists.
|
|
#
|
|
# Required Var Check
|
|
# require_var on SONARR_URL and SONARR_API_KEY before any request.
|
|
#
|
|
# API Reachability + Version Gate
|
|
# check_api then check_arr_version against SONARR_VERSION_MAJOR. A major version
|
|
# bump can move or rename the fields every rule here depends on, so a mismatch
|
|
# aborts rather than classifying against an unknown schema.
|
|
#
|
|
# Empty Library Abort
|
|
# A response of 0 series aborts. An empty list is indistinguishable from "nothing
|
|
# is misplaced" and would otherwise report a clean library during an API fault.
|
|
#
|
|
# Unconfigured Root Skip
|
|
# A blank SONARR_GENERAL_ROOT / KIDS_ROOT / ANIME_ROOT skips that category's
|
|
# checks rather than erroring — a host with no dedicated root is a valid setup,
|
|
# and a blank value must never be compared against as if it were a real path.
|
|
#
|
|
# One At A Time, Stop On First Failure
|
|
# Series are moved individually and the batch halts on the first failure rather
|
|
# than continuing. A misclassified root or a failing move is a condition to
|
|
# review, not to repeat across the library.
|
|
#
|
|
# Async Move Completion Polling
|
|
# moveFiles=true flips the DB instantly while the physical move is a separate
|
|
# async MoveSeries command Sonarr drains one at a time. Each move locates its own
|
|
# command and polls it to "completed" (bounded by SONARR_MOVE_POLL_TIMEOUT) before
|
|
# anything else is checked. Without this a batch reports every series moved while
|
|
# the files are still queued at the old path — and downstream orphan cleanup can
|
|
# act on that gap.
|
|
#
|
|
# Post-Move Re-Verification
|
|
# The PUT response is never trusted. The series is re-fetched and both
|
|
# rootFolderPath and episodeFileCount are confirmed against expectations before
|
|
# the move counts as successful.
|
|
#
|
|
# Reverse-Anime Leaks Excluded From Moves
|
|
# Deliberate style placements (Western/Chinese animation grouped with anime by
|
|
# choice) legitimately sit in the anime root. Those are reported, never moved.
|
|
#
|
|
# Post-Write Cache Refresh
|
|
# The tracked-data cache is refreshed after --move writes so no other arr script
|
|
# reads a stale rootFolderPath.
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# host*.conf
|
|
# SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT — existing, aliased by detect_hosts()
|
|
# SONARR_GENERAL_ROOT / SONARR_KIDS_ROOT / SONARR_ANIME_ROOT — rootFolderPath literals as
|
|
# reported by the API (e.g. "/tv", "/kids tv", "/ext-anime-shows") — leave blank on a
|
|
# host with no dedicated root for that category; the corresponding checks are skipped,
|
|
# not treated as an error.
|
|
#
|
|
# master.conf
|
|
# SONARR_ANIME_NETWORKS / SONARR_KIDS_NETWORKS — curated network allowlists
|
|
# SONARR_VERSION_MAJOR — expected API major version (reused from sonarr_cleanup.sh)
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# sonarr_classification_scan.sh — normal run, prints report
|
|
# sonarr_classification_scan.sh --log — verbose (per-series list)
|
|
# sonarr_classification_scan.sh --status — show config and exit
|
|
# sonarr_classification_scan.sh --move — act on forward misplacements + reverse-kids-leak
|
|
#
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
|
|
# --move is a script-local flag, not one parse_args recognizes — check the raw args before
|
|
# they get filtered into PARSED_ARGS.
|
|
MOVE_MODE=false
|
|
for _arg in "$@"; do
|
|
[[ "$_arg" == "--move" ]] && MOVE_MODE=true
|
|
done
|
|
unset _arg
|
|
|
|
parse_args "$@"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Setup ━━━
|
|
# ==============================================================================================
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
error "Must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
error "jq not found — required for JSON parsing"
|
|
exit 1
|
|
fi
|
|
|
|
detect_hosts
|
|
|
|
if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then
|
|
info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
|
|
exit 0
|
|
fi
|
|
|
|
require_var SONARR_URL
|
|
require_var SONARR_API_KEY
|
|
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
|
|
echo "$ICON_GEAR TV root: $SONARR_TV_ROOT"
|
|
echo "$ICON_GEAR General root: ${SONARR_GENERAL_ROOT:-<not configured>}"
|
|
echo "$ICON_GEAR Kids root: ${SONARR_KIDS_ROOT:-<not configured>}"
|
|
echo "$ICON_GEAR Anime root: ${SONARR_ANIME_ROOT:-<not configured>}"
|
|
echo "$ICON_GEAR Anime networks: ${#SONARR_ANIME_NETWORKS[@]} curated"
|
|
echo "$ICON_GEAR Kids networks: ${#SONARR_KIDS_NETWORKS[@]} curated"
|
|
echo "$ICON_GEAR Move mode: $MOVE_MODE"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Fetching Sonarr Library ━━━"
|
|
|
|
if ! check_api "$SONARR_URL" "Sonarr" 10; then
|
|
exit 1
|
|
fi
|
|
|
|
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
|
|
|
|
SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || {
|
|
error "Failed to fetch series from Sonarr"
|
|
exit 1
|
|
}
|
|
|
|
SERIES_COUNT=$(echo "$SERIES_RESPONSE" | jq -r 'length' 2>/dev/null)
|
|
if [[ -z "$SERIES_COUNT" ]] || [[ "$SERIES_COUNT" -eq 0 ]]; then
|
|
error "API returned 0 series — aborting"
|
|
exit 1
|
|
fi
|
|
info "$SERIES_COUNT series loaded"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Classify ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_CLEAN Classifying ━━━"
|
|
|
|
ANIME_NETWORKS_JSON=$(printf '%s\n' "${SONARR_ANIME_NETWORKS[@]}" | jq -R . | jq -s .)
|
|
KIDS_NETWORKS_JSON=$(printf '%s\n' "${SONARR_KIDS_NETWORKS[@]}" | jq -R . | jq -s .)
|
|
# Not just the US TV-MA/TV-14 tiers — non-US certification scales use different labels for the
|
|
# same "clearly adult" tier (confirmed live: "Tomb Raider: The Legend of Lara Croft" is "16+").
|
|
ADULT_CERT_JSON='["TV-MA","TV-14","MA15+","16","16+","18","15","14"]'
|
|
|
|
RESULTS=$(echo "$SERIES_RESPONSE" | jq \
|
|
--argjson animeNetworks "$ANIME_NETWORKS_JSON" \
|
|
--argjson kidsNetworks "$KIDS_NETWORKS_JSON" \
|
|
--argjson adultCert "$ADULT_CERT_JSON" \
|
|
--arg animeRoot "${SONARR_ANIME_ROOT:-}" \
|
|
--arg kidsRoot "${SONARR_KIDS_ROOT:-}" '
|
|
def is_anime:
|
|
(any(.genres[]?; . == "Anime")
|
|
and (.originalLanguage.name == "Japanese" or (.network as $n | $animeNetworks | index($n) != null)))
|
|
or (any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
|
|
or (.network as $n | $animeNetworks | index($n) != null);
|
|
def is_kids:
|
|
(is_anime | not) and (
|
|
any(.genres[]?; . == "Children")
|
|
or (.certification as $c | ["TV-Y","TV-Y7"] | index($c) != null)
|
|
or (.network as $n | $kidsNetworks | index($n) != null)
|
|
);
|
|
|
|
map(
|
|
{
|
|
title, id, network, certification, rootFolderPath,
|
|
episodeFileCount: (.statistics.episodeFileCount // 0),
|
|
is_anime: is_anime,
|
|
is_kids: is_kids
|
|
} |
|
|
. + {
|
|
forward_anime_miss: (.is_anime and $animeRoot != "" and .rootFolderPath != $animeRoot),
|
|
forward_kids_miss: (.is_kids and $kidsRoot != "" and .rootFolderPath != $kidsRoot),
|
|
reverse_anime_leak: ((.is_anime | not) and $animeRoot != "" and .rootFolderPath == $animeRoot),
|
|
reverse_kids_leak: ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
|
|
and (.certification as $c | $adultCert | index($c) != null))
|
|
}
|
|
)
|
|
')
|
|
|
|
FORWARD_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_anime_miss)] | length')
|
|
FORWARD_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_kids_miss)] | length')
|
|
REVERSE_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_anime_leak)] | length')
|
|
REVERSE_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_kids_leak)] | length')
|
|
|
|
if [[ "$ENABLE_LOGGING" == true ]]; then
|
|
echo "$RESULTS" | jq -r '.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_anime_leak or .reverse_kids_leak) |
|
|
" [\(if .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), network: \(.network // "n/a"), cert: \(.certification // "n/a"))"'
|
|
fi
|
|
|
|
# ── The reverse leaks, written down ───────────────────────────────────────────────────────────
|
|
# This block's own header says it does not act on REVERSE-ANIME leaks because they are genuine
|
|
# judgement calls. That is right, and it is also why they are the one result worth persisting:
|
|
# every other bucket either self-resolves or is acted on by --move, while these accumulate as a
|
|
# number in a summary nobody can do anything with. Seventeen of them hid two live-action dramas
|
|
# filed under anime for as long as the count stayed a count.
|
|
#
|
|
# Written as the script's own verdict so anything reading it — the triage that reads this next —
|
|
# inherits the classification rather than computing a second opinion from the same metadata.
|
|
# Report-only: this records what was found, it does not change what happens to any of it.
|
|
if [[ -n "${STATE_DIR:-}" ]] && [[ "$DRY_RUN" != true ]]; then
|
|
_review_file="$STATE_DIR/arr_classification_review.json"
|
|
echo "$RESULTS" | jq -c --arg host "$MY_ID" --argjson ts "$(date +%s)" '
|
|
{ host: $host, ts: $ts, arr: "sonarr",
|
|
reverse_anime: [ .[] | select(.reverse_anime_leak) |
|
|
{ title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // ""),
|
|
lang: (.originalLanguage.name // .originalLanguage // ""), id: .id } ],
|
|
reverse_kids: [ .[] | select(.reverse_kids_leak) |
|
|
{ title, root: .rootFolderPath, network: (.network // ""), cert: (.certification // "") } ] }
|
|
' > "$_review_file" 2>/dev/null \
|
|
&& log "$ICON_GEAR Review list written — $REVERSE_ANIME_COUNT anime leak(s) for triage" \
|
|
|| warn "Could not write $_review_file — triage will have nothing to read"
|
|
unset _review_file
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Summary ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY SONARR CLASSIFICATION SUMMARY ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_SYNC Series scanned: $SERIES_COUNT"
|
|
echo "$ICON_TRASH Forward — anime miss: $FORWARD_ANIME_COUNT (classified anime, outside ${SONARR_ANIME_ROOT:-<unconfigured>})"
|
|
echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${SONARR_KIDS_ROOT:-<unconfigured>})"
|
|
echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${SONARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
|
|
echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${SONARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
[[ "$ENABLE_LOGGING" != true ]] && echo " (run with --log for the per-title list)"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Move Mode ━━━
|
|
# ==============================================================================================
|
|
# See MOVE MODE in the header for scope (forward + reverse-kids-leak, not reverse-anime-leak).
|
|
if [[ "$MOVE_MODE" == true ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Move Mode ━━━"
|
|
|
|
acquire_lock "wait"
|
|
trap "_release_all_locks" EXIT
|
|
|
|
build_arr_path_map "SONARR"
|
|
|
|
MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak)]')
|
|
MOVE_COUNT=$(echo "$MOVE_TARGETS" | jq 'length')
|
|
|
|
if [[ "$MOVE_COUNT" -eq 0 ]]; then
|
|
info "Nothing to move"
|
|
exit 0
|
|
fi
|
|
|
|
warn "About to process $MOVE_COUNT series — one at a time, verifying after each"
|
|
|
|
MOVED=0
|
|
RELOCATED_SEARCH=0
|
|
FAILED=0
|
|
|
|
while IFS= read -r item; do
|
|
id=$(echo "$item" | jq -r '.id')
|
|
title=$(echo "$item" | jq -r '.title')
|
|
is_anime_flag=$(echo "$item" | jq -r '.is_anime')
|
|
is_forward_kids=$(echo "$item" | jq -r '.forward_kids_miss')
|
|
had_files_count=$(echo "$item" | jq -r '.episodeFileCount')
|
|
|
|
if [[ "$is_anime_flag" == "true" ]]; then
|
|
target_root="$SONARR_ANIME_ROOT"
|
|
elif [[ "$is_forward_kids" == "true" ]]; then
|
|
target_root="$SONARR_KIDS_ROOT"
|
|
else
|
|
target_root="$SONARR_GENERAL_ROOT"
|
|
fi
|
|
|
|
if [[ -z "$target_root" ]]; then
|
|
error " ✗ $title — target root not configured (SONARR_GENERAL_ROOT blank), skipping"
|
|
(( FAILED++ ))
|
|
continue
|
|
fi
|
|
|
|
# RESULTS only carries the reduced report fields — Sonarr's PUT expects the complete
|
|
# resource representation, so fetch a fresh full series record to modify and send back.
|
|
full_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr")
|
|
if [[ -z "$full_series" ]]; then
|
|
error " ✗ $title — could not fetch full series record, skipping"
|
|
(( FAILED++ ))
|
|
continue
|
|
fi
|
|
|
|
old_path=$(echo "$full_series" | jq -r '.path')
|
|
folder_name="${old_path##*/}"
|
|
|
|
# A literal "/" in the folder name would build a broken nested directory instead of
|
|
# moving to one clean folder — this is exactly the self-inflicted bug hit doing the
|
|
# Fate/Zero and Fate/Stay Night moves by hand earlier this session.
|
|
if [[ "$folder_name" == *"/"* ]]; then
|
|
error " ✗ $title — folder name contains '/', skipping (needs manual handling)"
|
|
(( FAILED++ ))
|
|
continue
|
|
fi
|
|
|
|
new_path="${target_root}/${folder_name}"
|
|
|
|
if [[ "$had_files_count" -gt 0 ]]; then
|
|
info " → $title: $old_path → $new_path (moving $had_files_count episode file(s))"
|
|
move_qs="?moveFiles=true"
|
|
else
|
|
info " → $title: $old_path → $new_path (no files — relocating + search)"
|
|
move_qs=""
|
|
fi
|
|
|
|
updated_series=$(echo "$full_series" | jq --arg root "$target_root" --arg path "$new_path" \
|
|
'.rootFolderPath = $root | .path = $path')
|
|
|
|
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X PUT \
|
|
--max-time 30 \
|
|
-H "X-Api-Key: $SONARR_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$updated_series" \
|
|
"${SONARR_URL}/api/v3/series/${id}${move_qs}" 2>/dev/null)
|
|
|
|
if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
|
|
error " ✗ $title — API returned HTTP $http_code — stopping (review before re-running)"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
|
|
# moveFiles=true flips rootFolderPath/episodeFileCount in the DB instantly, but the actual
|
|
# physical move is a separate async MoveSeries command that Sonarr drains one at a time
|
|
# internally — confirmed live: "Full House" showed episodeFileCount:192 at the new path via
|
|
# API while the real 75GB/192 files were still sitting at the old path, MoveSeries queued
|
|
# behind ~20 others. The DB-field check below cannot see that: poll the actual command to
|
|
# completion first, or a batch run can report every series "moved" while most are still
|
|
# mid-drain.
|
|
if [[ -n "$move_qs" ]]; then
|
|
move_cmd_id=""
|
|
for _ in 1 2 3 4 5; do
|
|
move_cmd_id=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \
|
|
"${SONARR_URL}/api/v3/command" 2>/dev/null | \
|
|
jq -r --argjson sid "$id" \
|
|
'[.[] | select(.name == "MoveSeries" and .body.seriesId == $sid)] | sort_by(.id) | last | .id // empty' \
|
|
2>/dev/null)
|
|
[[ -n "$move_cmd_id" ]] && break
|
|
sleep 1
|
|
done
|
|
|
|
if [[ -z "$move_cmd_id" ]]; then
|
|
error " ✗ $title — could not locate the MoveSeries command — stopping (review before re-running)"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
|
|
info " → $title: MoveSeries command $move_cmd_id queued, waiting for completion..."
|
|
move_status="" move_polled=0
|
|
while [[ "$move_polled" -lt "$SONARR_MOVE_POLL_TIMEOUT" ]]; do
|
|
move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \
|
|
"${SONARR_URL}/api/v3/command/${move_cmd_id}" 2>/dev/null | \
|
|
jq -r '.status // empty' 2>/dev/null)
|
|
[[ "$move_status" == "completed" || "$move_status" == "failed" ]] && break
|
|
sleep 10
|
|
(( move_polled += 10 ))
|
|
[[ $(( move_polled % 60 )) -eq 0 ]] && log " still moving $title... (${move_polled}s elapsed)"
|
|
done
|
|
|
|
if [[ "$move_status" != "completed" ]]; then
|
|
error " ✗ $title — MoveSeries command $move_cmd_id ended as '${move_status:-timed out after ${SONARR_MOVE_POLL_TIMEOUT}s}' — stopping"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
fi
|
|
|
|
sleep 3
|
|
|
|
# Never trust the PUT response alone — re-fetch and confirm the change actually landed.
|
|
# This exact check is what caught the earlier race condition doing this by hand: two
|
|
# series reported "success" while episodeFileCount had silently dropped to 0.
|
|
verify_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr")
|
|
verify_root=$(echo "$verify_series" | jq -r '.rootFolderPath')
|
|
verify_filecount=$(echo "$verify_series" | jq -r '.statistics.episodeFileCount // 0')
|
|
|
|
if [[ "$verify_root" != "$target_root" ]]; then
|
|
error " ✗ $title — verification failed (root: $verify_root) — stopping"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
|
|
if [[ "$had_files_count" -gt 0 ]]; then
|
|
if [[ "$verify_filecount" -eq "$had_files_count" ]]; then
|
|
echo " $ICON_SUCCESS $title — moved and verified ($verify_filecount files)"
|
|
(( MOVED++ ))
|
|
else
|
|
error " ✗ $title — verification failed (root updated but episode count $verify_filecount != expected $had_files_count) — stopping"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
else
|
|
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
|
|
--max-time 30 \
|
|
-H "X-Api-Key: $SONARR_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"name\":\"SeriesSearch\",\"seriesId\":${id}}" \
|
|
"${SONARR_URL}/api/v3/command" 2>/dev/null)
|
|
if [[ "$search_code" == "200" || "$search_code" == "201" ]]; then
|
|
echo " $ICON_SUCCESS $title — relocated, search triggered"
|
|
else
|
|
warn " $title — relocated but search trigger returned HTTP $search_code (will pick up on next scheduled search)"
|
|
fi
|
|
(( RELOCATED_SEARCH++ ))
|
|
fi
|
|
done < <(echo "$MOVE_TARGETS" | jq -c '.[]')
|
|
|
|
# arr_get_tracked_data() is cache-first — every write above changed rootFolderPath, so the
|
|
# shared cache is now stale until the next scheduled arr_cache_prefill run. Refresh it now
|
|
# rather than leave that window open for every other script reading this cache.
|
|
if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
|
|
info "Refreshing shared tracked-data cache..."
|
|
fresh_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series" "Sonarr")
|
|
[[ -n "$fresh_series" ]] && arr_cache_write "sonarr" "$fresh_series"
|
|
fi
|
|
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
|
|
echo "$ICON_SUCCESS Moved (files relocated): $MOVED"
|
|
echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH"
|
|
echo "$ICON_ERROR Failed: $FAILED"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
fi
|
|
|
|
exit 0
|