#!/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:-}" echo "$ICON_GEAR Anime root: ${RADARR_ANIME_ROOT:-}" 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, 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:-})" echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${RADARR_KIDS_ROOT:-})" echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${RADARR_ANIME_ROOT:-}, no anime signal — review, may be deliberate style placement)" echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${RADARR_KIDS_ROOT:-}, 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" # 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. 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 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') had_file=$(echo "$item" | jq -r '.hasFile') 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}" 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 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