Headers claimed protections the code never had, and several destructive paths had no guard against a collapsed config value.
649 lines
32 KiB
Bash
Executable File
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
|