Files
Varaverk/Arrs_Stack/radarr_classification_scan.sh
T
Gmer4Lfe e8b114094a Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no
guard against a collapsed config value.
2026-08-01 20:37:59 -04:00

649 lines
32 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ============================ Radarr Content Classification Scan ==============================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Overseerr lets any user request a movie into the wrong root folder (kids content added to
# the general Movies share, anime added to Kids_Movies, etc.) and most users never notice or
# correct it. This script reads Radarr's tracked movie list and classifies every movie as
# anime / kids-only / regular using metadata signals alone (genre, certification, studio,
# original language) — then reports where a movie's computed classification disagrees with
# the root folder it's actually sitting in, in both directions:
#
# FORWARD — a movie classified as anime/kids is sitting outside its dedicated root
# REVERSE — a movie sitting inside the kids/anime root doesn't match that classification
#
# Report-only by default — no files are moved and no Radarr API writes happen unless a
# mode flag is given. Pass --move to relocate forward misplacements, or --remove-junk to
# delete and import-exclude bad-metadata entries (see OPERATIONAL MODEL below); without
# those flags this is purely a detection tool. Every rule below was validated against
# this library's real data before being
# adopted (see master.conf comments above the curated lists) — this is not a generic
# genre-matcher, it's tuned specifically against the false-positive traps that showed up
# when testing looser rules (documented per-rule below).
#
# ==============================================================================================
# CLASSIFICATION RULES
# ==============================================================================================
#
# is_anime:
# (genre Animation AND originalLanguage Japanese) OR studio in RADARR_ANIME_STUDIOS
# Always wins over kids when both could apply — explicit priority, not a tiebreak.
#
# is_kids ("kids will end up watching this alone" — NOT "family movie night"):
# not is_anime AND certification not in (R, NC-17) AND (
# (genre Animation AND certification != PG-13)
# OR studio in RADARR_KIDS_STUDIOS
# )
# Deliberately excludes bare "Family" genre and bare "G" certification — both genuinely
# traced back to live-action films the whole household watches together (Mrs. Doubtfire,
# Doctor Dolittle, National Treasure-style adventures, classic Westerns), not kids-only
# content. Family movie night stays in the general Movies root by design.
#
# The PG-13 exclusion on the Animation branch is load-bearing — without it this rule
# catches South Park movies, Sausage Party, "9", Resident Evil: Death Island, and (via
# the curated studio list) Warner Bros. Animation's R-rated Watchmen films, since that
# studio makes both kids content and adult content under the same name.
#
# is_junk (bad/thin TMDb match, not a real classification problem):
# hasFile == false AND imdbId == null AND tmdb votes < RADARR_JUNK_MIN_VOTES
# Caught live: two fake "X-Men"/"Wolverine" entries, two fake "Silent Hill" entries, one
# fake "The Purge" spinoff — all monitored placeholders with nothing behind them. The
# fix for these is removal from Radarr, not blocklist+redownload — there's no release to
# blocklist and likely nothing legitimate to redownload under that exact TMDb match.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Report pass (always):
# check_api → check_arr_version → arr_get_tracked_data (cache-first, one call)
# → classify every movie → report FORWARD, REVERSE and JUNK findings
#
# Remove-junk pass (--remove-junk, runs first when combined with --move):
# One entry at a time, halting on the first failure.
# DELETE with deleteFiles=false and addImportExclusion=true — the Radarr entry is
# removed and blocked from re-adding, files on disk are never touched. is_junk
# requires hasFile == false, so there is no file behind these entries anyway.
# Verified by re-fetching and requiring a 404 before counting as removed.
#
# Move pass (--move):
# One movie at a time, verified after each.
# hasFile == true → moveFiles=true, then poll the async MoveMovie command to
# "completed" (bounded by RADARR_MOVE_POLL_TIMEOUT) before the
# DB-field check — the DB flips instantly while the physical
# move is still queued.
# hasFile == false → correct rootFolderPath/path and trigger MoviesSearch instead;
# there is nothing to move.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Report by Default, Act Only on Request
# A bare run never calls Radarr's write API and never touches a file — every finding is
# just a candidate. Acting on them requires an explicit --move or --remove-junk flag, so
# the scan can be scheduled and re-run freely while the curated lists are being tuned
# without any risk of it rearranging the library on its own.
#
# Curated Lists, Not Bare Genre/Cert Matching
# Every signal used here failed at least once as a bare/standalone check during rule
# development (Family genre, G certification, blanket Animation genre, bare Anime genre
# tag, Disney+/general-platform networks) — see master.conf comments for what each
# curated list deliberately excludes and why.
#
# Cache-First, Never a Per-Movie Call
# Uses arr_get_tracked_data() same as radarr_cleanup.sh — Radarr's movie list already
# embeds everything this script needs per movie, so this is a single API call (or zero,
# if the shared cache is warm) regardless of library size.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Enforcement
# Required by the container interaction and state writes.
#
# Lock Acquisition
# acquire_lock, plus acquire_lock "wait" around the write passes with an EXIT trap
# releasing all locks, so an interrupted run never leaves a lock behind.
#
# Host Detection
# detect_hosts() aliases RADARR_URL / RADARR_API_KEY / the root literals.
#
# curl + jq Dependency Check
# Fails fast if either is missing — every classification signal is parsed with jq.
#
# Report-Only Default
# No write happens without --move or --remove-junk.
#
# Required Var Check
# require_var on RADARR_URL and RADARR_API_KEY before any request.
#
# API Reachability + Version Gate
# check_api then check_arr_version against RADARR_VERSION_MAJOR. A major version bump
# can move or rename the fields every rule depends on, so a mismatch aborts rather
# than classifying against an unknown schema.
#
# Empty Library Abort
# A response of 0 movies aborts — an empty list is indistinguishable from a clean
# library and would otherwise report success during an API fault.
#
# Unconfigured Root Skip
# A blank kids/anime root skips that category's checks rather than comparing paths
# against an empty string.
#
# Files Never Deleted
# Junk removal passes deleteFiles=false. Only the Radarr entry is removed, and
# addImportExclusion=true stops it being re-added. is_junk additionally requires
# hasFile == false, so these entries have nothing on disk in the first place.
#
# One At A Time, Stop On First Failure
# Both write passes process one entry at a time and halt on the first failure rather
# than continuing through the library.
#
# Post-Write Verification
# Removal is confirmed by re-fetching and requiring a 404. Moves are confirmed by
# re-fetching and checking rootFolderPath and hasFile. The API response alone is
# never treated as proof.
#
# Async Move Completion Polling
# moveFiles=true flips the DB instantly while the physical move is a separate async
# MoveMovie command. Each move polls its own command to "completed" (bounded by
# RADARR_MOVE_POLL_TIMEOUT) before the DB-field check, so a batch cannot report
# everything moved while files are still queued at the old path.
#
# Junk Vote Threshold
# RADARR_JUNK_MIN_VOTES gates junk detection alongside hasFile == false and a null
# imdbId. All three must hold — a thin-metadata entry that actually has a file, or
# has an IMDb ID, is never treated as junk.
#
# Post-Write Cache Refresh
# The tracked-data cache is refreshed after writes so no other arr script reads a
# stale rootFolderPath or a movie that no longer exists.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# RADARR_URL / RADARR_API_KEY / RADARR_MOVIES_ROOT — existing, aliased by detect_hosts()
# RADARR_KIDS_ROOT / RADARR_ANIME_ROOT — rootFolderPath literals as reported by the API
# (e.g. "/kids movies", "/ext-anime-movies") — leave blank on a host with no dedicated
# root for that category; the corresponding checks are skipped, not treated as an error.
#
# master.conf
# RADARR_ANIME_STUDIOS / RADARR_KIDS_STUDIOS — curated studio allowlists
# RADARR_JUNK_MIN_VOTES — TMDb vote threshold for the bad-metadata check
# RADARR_VERSION_MAJOR — expected API major version (reused from radarr_cleanup.sh)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# radarr_classification_scan.sh — normal run, prints report
# radarr_classification_scan.sh --log — verbose (per-movie TRACKED-style logging)
# radarr_classification_scan.sh --status — show config and exit
# radarr_classification_scan.sh --remove-junk — delete + import-exclude bad-metadata entries
# radarr_classification_scan.sh --move — relocate forward misplacements (moves the
# file if one exists; for hasFile=false
# entries, just corrects rootFolderPath/path
# and triggers an immediate MoviesSearch)
#
# --remove-junk and --move can be combined in one run — junk is cleared first, then the
# move pass runs against the remaining (now junk-free) classification.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# --move / --remove-junk are script-local flags, not ones parse_args recognizes — check the
# raw args before they get filtered into PARSED_ARGS.
MOVE_MODE=false
REMOVE_JUNK_MODE=false
for _arg in "$@"; do
[[ "$_arg" == "--move" ]] && MOVE_MODE=true
[[ "$_arg" == "--remove-junk" ]] && REMOVE_JUNK_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 "${RADARR_URL:-}" ]] || [[ -z "${RADARR_API_KEY:-}" ]]; then
info "Radarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
require_var RADARR_URL
require_var RADARR_API_KEY
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Movies root: $RADARR_MOVIES_ROOT"
echo "$ICON_GEAR General root: ${RADARR_GENERAL_ROOT:-<not configured>}"
echo "$ICON_GEAR Kids root: ${RADARR_KIDS_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime root: ${RADARR_ANIME_ROOT:-<not configured>}"
echo "$ICON_GEAR Anime studios: ${#RADARR_ANIME_STUDIOS[@]} curated"
echo "$ICON_GEAR Kids studios: ${#RADARR_KIDS_STUDIOS[@]} curated"
echo "$ICON_GEAR Junk min votes: ${RADARR_JUNK_MIN_VOTES:-15}"
echo "$ICON_GEAR Move mode: $MOVE_MODE"
echo "$ICON_GEAR Remove-junk: $REMOVE_JUNK_MODE"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ $ICON_SYNC Fetching Radarr Library ━━━"
if ! check_api "$RADARR_URL" "Radarr" 10; then
exit 1
fi
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
MOVIES_RESPONSE=$(arr_get_tracked_data "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") || {
error "Failed to fetch movies from Radarr"
exit 1
}
MOVIE_COUNT=$(echo "$MOVIES_RESPONSE" | jq -r 'length' 2>/dev/null)
if [[ -z "$MOVIE_COUNT" ]] || [[ "$MOVIE_COUNT" -eq 0 ]]; then
error "API returned 0 movies — aborting"
exit 1
fi
info "$MOVIE_COUNT movies loaded"
# ==============================================================================================
# ━━━ Classify ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Classifying ━━━"
ANIME_STUDIOS_JSON=$(printf '%s\n' "${RADARR_ANIME_STUDIOS[@]}" | jq -R . | jq -s .)
KIDS_STUDIOS_JSON=$(printf '%s\n' "${RADARR_KIDS_STUDIOS[@]}" | jq -R . | jq -s .)
JUNK_MIN_VOTES="${RADARR_JUNK_MIN_VOTES:-15}"
RESULTS=$(echo "$MOVIES_RESPONSE" | jq \
--argjson animeStudios "$ANIME_STUDIOS_JSON" \
--argjson kidsStudios "$KIDS_STUDIOS_JSON" \
--arg animeRoot "${RADARR_ANIME_ROOT:-}" \
--arg kidsRoot "${RADARR_KIDS_ROOT:-}" \
--argjson junkMinVotes "$JUNK_MIN_VOTES" '
def is_anime:
(any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese")
or (.studio as $s | $animeStudios | index($s) != null);
def not_adult: (.certification != "R") and (.certification != "NC-17");
def is_kids:
(is_anime | not) and not_adult and (
(any(.genres[]?; . == "Animation") and .certification != "PG-13")
or (.studio as $s | $kidsStudios | index($s) != null)
);
def is_junk:
(.hasFile == false) and (.imdbId == null)
and ((.ratings.tmdb.votes // 999999) < $junkMinVotes);
map(
{
title, id, studio, certification, rootFolderPath, genres, hasFile,
is_anime: is_anime,
is_kids: is_kids,
is_junk: is_junk
} |
. + {
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 and (.is_junk | not)),
reverse_kids_leak: ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot
and (.is_junk | not)
and (.certification == "R" or .certification == "NC-17"
or (.certification == "PG-13"
and (any(.genres[]?; . == "Family") | not)
and (any(.genres[]?; . == "Animation") | not))))
}
)
')
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')
JUNK_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.is_junk)] | 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 or .is_junk) |
" [\(if .is_junk then "JUNK" elif .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), studio: \(.studio // "n/a"), cert: \(.certification // "n/a"))"'
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR CLASSIFICATION SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Movies scanned: $MOVIE_COUNT"
echo "$ICON_TRASH Forward — anime miss: $FORWARD_ANIME_COUNT (classified anime, outside ${RADARR_ANIME_ROOT:-<unconfigured>})"
echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${RADARR_KIDS_ROOT:-<unconfigured>})"
echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${RADARR_ANIME_ROOT:-<unconfigured>}, no anime signal — review, may be deliberate style placement)"
echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${RADARR_KIDS_ROOT:-<unconfigured>}, adult-rated content)"
echo "$ICON_PROTECTED Bad metadata (junk): $JUNK_COUNT (hasFile=false, no imdbId, thin TMDb match — candidates for removal, not redownload)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ENABLE_LOGGING" != true ]] && echo " (run with --log for the per-title list)"
# ==============================================================================================
# ━━━ Remove-Junk Mode ━━━
# ==============================================================================================
# Junk entries are bad/thin TMDb matches with hasFile=false — there's no release to blocklist
# (nothing was ever grabbed) and no file to delete, only a bad monitored record. The fix is
# removing the record and adding it to Radarr's import exclusion list (same mechanism
# radarr_tmdb_removed.sh already uses via RADARR_DROPPED_ADD_EXCLUSION) so the same bad TMDb
# match can't get re-added by a future Overseerr request or list sync.
if [[ "$REMOVE_JUNK_MODE" == true ]]; then
echo ""
echo "━━━ $ICON_TRASH Remove-Junk Mode ━━━"
acquire_lock "wait"
trap "_release_all_locks" EXIT
JUNK_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.is_junk)]')
JUNK_TARGET_COUNT=$(echo "$JUNK_TARGETS" | jq 'length')
if [[ "$JUNK_TARGET_COUNT" -eq 0 ]]; then
info "No junk entries to remove"
else
warn "About to remove $JUNK_TARGET_COUNT junk entries — one at a time, verifying after each"
JUNK_REMOVED=0
JUNK_FAILED=0
while IFS= read -r item; do
id=$(echo "$item" | jq -r '.id')
title=$(echo "$item" | jq -r '.title')
http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
--max-time 15 \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/${id}?deleteFiles=false&addImportExclusion=true" 2>/dev/null)
if [[ "$http_code" != "200" && "$http_code" != "202" ]]; then
error " ✗ $title — API returned HTTP $http_code — stopping (review before re-running)"
(( JUNK_FAILED++ ))
break
fi
sleep 1
# Verify — the movie should now be gone entirely (404).
verify_code=$(curl -sf -o /dev/null -w "%{http_code}" \
-H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/movie/${id}" 2>/dev/null)
if [[ "$verify_code" == "404" ]]; then
echo " $ICON_SUCCESS $title — removed and excluded"
(( JUNK_REMOVED++ ))
else
error " ✗ $title — verification failed (still returns HTTP $verify_code) — stopping"
(( JUNK_FAILED++ ))
break
fi
done < <(echo "$JUNK_TARGETS" | jq -c '.[]')
if [[ "$JUNK_REMOVED" -gt 0 ]]; then
info "Refreshing shared tracked-data cache..."
fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
[[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY REMOVE-JUNK SUMMARY ━━━━━"
echo "$ICON_SUCCESS Removed: $JUNK_REMOVED"
echo "$ICON_ERROR Failed: $JUNK_FAILED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
_release_all_locks
trap - EXIT
fi
# ==============================================================================================
# ━━━ Move Mode ━━━
# ==============================================================================================
# Acts on FORWARD misplacements (clear-cut: classified anime/kids, sitting in the wrong root)
# and on REVERSE-KIDS leaks (adult certification with zero Family/Animation genre sitting in
# the kids root — also clear-cut, moved back to RADARR_GENERAL_ROOT). Does NOT act on
# REVERSE-ANIME leaks — those are genuine judgment calls, since deliberate style placements
# like Castlevania/Legend of Korra legitimately live in the anime root without matching the
# anime signal — or on JUNK (those need removal from Radarr, not a file move).
#
# One movie at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/
# hasFile) instantly, but the physical move is a separate async MoveMovie command Radarr's
# own MoveMovieService drains one at a time internally — same architecture that raced on the
# Sonarr side (episodeFileCount reported at the new path via API while the real files were
# still sitting at the old one, MoveSeries command queued behind ~20 others). Never confirmed
# live on the Radarr side, but the same DB-write-is-instant/move-is-async split applies, so
# each move here polls its own MoveMovie command to "completed" before the DB-field check
# runs, mirroring the Sonarr fix.
if [[ "$MOVE_MODE" == true ]]; then
echo ""
echo "━━━ $ICON_SYNC Move Mode — Forward Misplacements ━━━"
acquire_lock "wait"
trap "_release_all_locks" EXIT
build_arr_path_map "RADARR"
# hasFile==false entries (monitored but never downloaded) have nothing to physically
# move — confirmed live: "Biohazard 4: Incubate" had hasFile=false even in the cache from
# before any of this ran, not something this script broke. There's still real value in
# fixing them, though: correct the DB pointer now (so Radarr saves to the right root
# whenever it does find a release) and kick off an immediate search rather than waiting
# for the next scheduled one. Junk entries are still excluded entirely — nothing to
# search for there, they need removal instead.
#
# reverse_kids_leak is included here (unlike reverse_anime_leak) because its signal is
# specifically "adult certification with zero Family/Animation genre" — there's no
# legitimate stylistic reason for that combination to sit in a curated kids library, unlike
# the anime side where deliberate style placements (Castlevania, Legend of Korra) are
# common and valid. Confirmed live: the 4 titles this caught (The Addams Family, Saving
# Mr. Banks, Dark Shadows, The DUFF) are all genuinely non-kids content, not judgment calls.
MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select((.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak) and (.is_junk | not))]')
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 movies — 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_file=$(echo "$item" | jq -r '.hasFile')
if [[ "$is_anime_flag" == "true" ]]; then
target_root="$RADARR_ANIME_ROOT"
elif [[ "$is_forward_kids" == "true" ]]; then
target_root="$RADARR_KIDS_ROOT"
else
target_root="$RADARR_GENERAL_ROOT"
fi
if [[ -z "$target_root" ]]; then
error " ✗ $title — target root not configured (RADARR_GENERAL_ROOT blank), skipping"
(( FAILED++ ))
continue
fi
# RESULTS only carries the reduced report fields — Radarr's PUT expects the complete
# resource representation, so fetch a fresh full movie record to modify and send back.
full_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
if [[ -z "$full_movie" ]]; then
error " ✗ $title — could not fetch full movie record, skipping"
(( FAILED++ ))
continue
fi
old_path=$(echo "$full_movie" | 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 — bit us once already doing this by hand for Sonarr.
if [[ "$folder_name" == *"/"* ]]; then
error " ✗ $title — folder name contains '/', skipping (needs manual handling)"
(( FAILED++ ))
continue
fi
new_path="${target_root}/${folder_name}"
if [[ "$had_file" == "true" ]]; then
info " → $title: $old_path$new_path (moving file)"
move_qs="?moveFiles=true"
else
info " → $title: $old_path$new_path (no file — relocating + search)"
move_qs=""
fi
updated_movie=$(echo "$full_movie" | 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: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$updated_movie" \
"${RADARR_URL}/api/v3/movie/${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/hasFile in the DB instantly, but the actual
# physical move is a separate async MoveMovie command that Radarr's MoveMovieService
# drains one at a time internally — mirrors the confirmed Sonarr race (see header
# comment above Move Mode). Poll the actual command to completion before trusting the
# DB-field check below.
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: $RADARR_API_KEY" \
"${RADARR_URL}/api/v3/command" 2>/dev/null | \
jq -r --argjson mid "$id" \
'[.[] | select(.name == "MoveMovie" and .body.movieId == $mid)] | 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 MoveMovie command — stopping (review before re-running)"
(( FAILED++ ))
break
fi
info " → $title: MoveMovie command $move_cmd_id queued, waiting for completion..."
move_status="" move_polled=0
while [[ "$move_polled" -lt "$RADARR_MOVE_POLL_TIMEOUT" ]]; do
move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $RADARR_API_KEY" \
"${RADARR_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 — MoveMovie command $move_cmd_id ended as '${move_status:-timed out after ${RADARR_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.
verify_movie=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie/$id" "Radarr")
verify_root=$(echo "$verify_movie" | jq -r '.rootFolderPath')
verify_hasfile=$(echo "$verify_movie" | jq -r '.hasFile')
if [[ "$verify_root" != "$target_root" ]]; then
error " ✗ $title — verification failed (root: $verify_root) — stopping"
(( FAILED++ ))
break
fi
if [[ "$had_file" == "true" ]]; then
if [[ "$verify_hasfile" == "true" ]]; then
echo " $ICON_SUCCESS $title — moved and verified"
(( MOVED++ ))
else
error " ✗ $title — verification failed (root updated but hasFile now false) — stopping"
(( FAILED++ ))
break
fi
else
search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
--max-time 30 \
-H "X-Api-Key: $RADARR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"MoviesSearch\",\"movieIds\":[${id}]}" \
"${RADARR_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 (up to 30min).
# Every other script reading this cache (cleanup, discovery, etc.) would see wrong data
# until then — refresh it now with one more live fetch rather than leave that window open.
if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then
info "Refreshing shared tracked-data cache..."
fresh_movies=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr")
[[ -n "$fresh_movies" ]] && arr_cache_write "radarr" "$fresh_movies"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
echo "$ICON_SUCCESS Moved (file relocated): $MOVED"
echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH"
echo "$ICON_ERROR Failed: $FAILED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
exit 0