Acts only on forward misplacements (clear-cut anime/kids classified content sitting outside its dedicated root) — reverse leaks and junk entries stay report-only, since those need human judgment or a different action (removal, not a move). One movie at a time with a real re-fetch-and-verify after each, matching the race condition found doing this by hand for Sonarr earlier in the session.
358 lines
18 KiB
Bash
Executable File
358 lines
18 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. No files are moved and no Radarr API writes happen — this is 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.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# Report, Don't Act
|
|
# This script never calls Radarr's write API and never touches a file. Every finding is
|
|
# a candidate for a human decision — moving media and re-pointing Radarr's tracking is a
|
|
# separate, deliberate follow-up action, not something this scan does automatically.
|
|
#
|
|
# 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.
|
|
#
|
|
# ==============================================================================================
|
|
# 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
|
|
#
|
|
# ==============================================================================================
|
|
|
|
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 "${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 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 "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
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,
|
|
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)"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Move Mode ━━━
|
|
# ==============================================================================================
|
|
# Acts only on FORWARD misplacements (clear-cut: classified anime/kids, sitting in the wrong
|
|
# root) and never on REVERSE leaks (those are judgment calls — deliberate style placements
|
|
# like Castlevania/Legend of Korra live there, so they're report-only) or JUNK (those need
|
|
# removal from Radarr, not a file move — nothing to move for a hasFile=false entry).
|
|
#
|
|
# One movie at a time, verified after each, matching the lesson learned doing this by hand
|
|
# for Sonarr earlier: a rapid-fire batch of moves raced Radarr's own file-move worker and
|
|
# left two series' files at an intermediate path while the API still reported success. A
|
|
# short sleep plus a real re-fetch-and-check after every single move catches that here
|
|
# before it can compound across dozens of movies.
|
|
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"
|
|
|
|
MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select((.forward_anime_miss or .forward_kids_miss) 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 move $MOVE_COUNT movies — one at a time, verifying after each"
|
|
|
|
MOVED=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')
|
|
if [[ "$is_anime_flag" == "true" ]]; then
|
|
target_root="$RADARR_ANIME_ROOT"
|
|
else
|
|
target_root="$RADARR_KIDS_ROOT"
|
|
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}"
|
|
info " → $title: $old_path → $new_path"
|
|
|
|
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}?moveFiles=true" 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
|
|
|
|
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" ]] && [[ "$verify_hasfile" == "true" ]]; then
|
|
echo " $ICON_SUCCESS $title — moved and verified"
|
|
(( MOVED++ ))
|
|
else
|
|
error " ✗ $title — verification failed (root: $verify_root, hasFile: $verify_hasfile) — stopping"
|
|
(( FAILED++ ))
|
|
break
|
|
fi
|
|
done < <(echo "$MOVE_TARGETS" | jq -c '.[]')
|
|
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━"
|
|
echo "$ICON_SUCCESS Moved: $MOVED"
|
|
echo "$ICON_ERROR Failed: $FAILED"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
fi
|
|
|
|
exit 0
|