From 9dbcdc0bd7da0085f000e94700c73a44b56ce4f6 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 17 Jul 2026 11:49:23 -0400 Subject: [PATCH] Add anime/kids content misclassification detection for Radarr and Sonarr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overseerr lets users request content into the wrong root folder; these new report-only scans classify every tracked movie/series (anime, kids, regular) from metadata alone and flag mismatches against the actual root folder, in both directions. Rules were validated against real library data before being adopted — see the header comments in each script and the master.conf notes above the curated lists. --- Arrs_Stack/radarr_classification_scan.sh | 240 +++++++++++++++++++++++ Arrs_Stack/sonarr_classification_scan.sh | 224 +++++++++++++++++++++ Deployment/host.conf.template | 4 + Deployment/master.conf.template | 49 +++++ common.sh | 4 + 5 files changed, 521 insertions(+) create mode 100755 Arrs_Stack/radarr_classification_scan.sh create mode 100755 Arrs_Stack/sonarr_classification_scan.sh diff --git a/Arrs_Stack/radarr_classification_scan.sh b/Arrs_Stack/radarr_classification_scan.sh new file mode 100755 index 0000000..21d4932 --- /dev/null +++ b/Arrs_Stack/radarr_classification_scan.sh @@ -0,0 +1,240 @@ +#!/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" + +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 "━━━━━━━━━━━━━━━━━━━━━━━" + 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, + 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)" + +exit 0 diff --git a/Arrs_Stack/sonarr_classification_scan.sh b/Arrs_Stack/sonarr_classification_scan.sh new file mode 100755 index 0000000..2bd3fab --- /dev/null +++ b/Arrs_Stack/sonarr_classification_scan.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# ============================================================================================== +# ============================ Sonarr Content Classification Scan ============================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Same problem as radarr_classification_scan.sh, TV side: Overseerr lets any user request a +# show into the wrong root folder (kids shows added to the general TV share, anime added to +# Kids_Tv_Shows, etc.). This script reads Sonarr's tracked series list and classifies every +# series as anime / kids-only / regular using metadata signals alone (genre, certification, +# network, original language) — then reports where a series' computed classification +# disagrees with the root folder it's actually sitting in, in both directions: +# +# FORWARD — a series classified as anime/kids is sitting outside its dedicated root +# REVERSE — a series sitting inside the kids/anime root doesn't match that classification +# +# Report-only. No files are moved and no Sonarr API writes happen. Every rule below was +# validated against this library's real data before being adopted — see the companion +# comment block in master.conf above the curated lists. +# +# ============================================================================================== +# CLASSIFICATION RULES — DIFFERENT FIELD MODEL THAN RADARR, NOT A COPY-PASTE +# ============================================================================================== +# +# TV metadata (TheTVDB, via Sonarr) shapes these signals differently than movie metadata +# (TMDb, via Radarr) — every difference below was confirmed live, not assumed: +# - Sonarr has an explicit "Anime" genre tag; Radarr does not. +# - Sonarr uses a single "network" field (TheTVDB's broadcaster), not "studio". +# - Certification is on the US TV Parental Guidelines scale (TV-Y/TV-Y7/TV-G/TV-PG/ +# TV-14/TV-MA), not the MPAA scale — the tiers do not mean the same thing at the same +# position (TV-G is "general audience", not "for children", unlike movie G). +# +# is_anime: +# genre "Anime" (corroborated by Japanese language OR a Japan network — the bare tag alone +# produced a real false positive: "Craig of the Creek", an all-American Cartoon Network +# show, carries an "Anime" genre tag on TheTVDB for no discernible reason) +# OR (genre Animation AND originalLanguage Japanese) +# OR network in SONARR_ANIME_NETWORKS +# Always wins over kids when both could apply — explicit priority, not a tiebreak. +# +# is_kids ("kids will end up watching this alone" — NOT "family show night"): +# not is_anime AND ( +# genre "Children" (NOT "Family" — see below) +# OR certification in (TV-Y, TV-Y7) (NOT TV-G — see below) +# OR network in SONARR_KIDS_NETWORKS +# ) +# "Family" genre and "TV-G" certification were both tested standalone and rejected — +# both catch general-audience live-action content the whole household watches together +# (I Love Lucy, The Brady Bunch, Full House, Homestead Rescue), not kids-only content. +# Blanket "Animation" genre was also tested and rejected — it's dominated on TV by adult +# animated sitcoms (Rick and Morty, BoJack Horseman, Family Guy, South Park), unlike the +# movie side where it's a usable (gated) signal. +# +# No junk-detection tier here (unlike Radarr) — TheTVDB's ratings/imdbId data is far +# sparser than TMDb's even for completely legitimate shows (confirmed live: "The Pussycat +# Dolls Present: The Search for the Next Doll", a real 2007 MTV show, has ratings.votes=0 +# and imdbId=null) — the vote-count heuristic that works for Radarr would flag real content +# for removal here, so it's deliberately not reused. +# +# ============================================================================================== +# DESIGN PRINCIPLES +# ============================================================================================== +# +# Report, Don't Act — same as the Radarr scan; moving/re-pointing is a deliberate follow-up. +# Curated Lists, Not Bare Genre/Cert Matching — see master.conf comments for exclusions. +# Cache-First — arr_get_tracked_data() same as sonarr_cleanup.sh, single call regardless +# of library size. +# +# ============================================================================================== +# CONFIGURATION +# ============================================================================================== +# +# host*.conf +# SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT — existing, aliased by detect_hosts() +# SONARR_KIDS_ROOT / SONARR_ANIME_ROOT — rootFolderPath literals as reported by the API +# (e.g. "/kids tv", "/ext-anime-shows") — leave blank on a host with no dedicated root +# for that category; the corresponding checks are skipped, not treated as an error. +# +# master.conf +# SONARR_ANIME_NETWORKS / SONARR_KIDS_NETWORKS — curated network allowlists +# SONARR_VERSION_MAJOR — expected API major version (reused from sonarr_cleanup.sh) +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# sonarr_classification_scan.sh — normal run, prints report +# sonarr_classification_scan.sh --log — verbose (per-series list) +# sonarr_classification_scan.sh --status — show config and exit +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +# ============================================================================================== +# ━━━ Setup ━━━ +# ============================================================================================== +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + error "jq not found — required for JSON parsing" + exit 1 +fi + +detect_hosts + +if [[ -z "${SONARR_URL:-}" ]] || [[ -z "${SONARR_API_KEY:-}" ]]; then + info "Sonarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping" + exit 0 +fi + +require_var SONARR_URL +require_var SONARR_API_KEY + +if [[ "$SHOW_STATUS" == true ]]; then + echo "" + echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" + echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" + echo "$ICON_GEAR Sonarr URL: $SONARR_URL" + echo "$ICON_GEAR TV root: $SONARR_TV_ROOT" + echo "$ICON_GEAR Kids root: ${SONARR_KIDS_ROOT:-}" + echo "$ICON_GEAR Anime root: ${SONARR_ANIME_ROOT:-}" + echo "$ICON_GEAR Anime networks: ${#SONARR_ANIME_NETWORKS[@]} curated" + echo "$ICON_GEAR Kids networks: ${#SONARR_KIDS_NETWORKS[@]} curated" + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +fi + +echo "" +echo "━━━ $ICON_SYNC Fetching Sonarr Library ━━━" + +if ! check_api "$SONARR_URL" "Sonarr" 10; then + exit 1 +fi + +check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1 + +SERIES_RESPONSE=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") || { + error "Failed to fetch series from Sonarr" + exit 1 +} + +SERIES_COUNT=$(echo "$SERIES_RESPONSE" | jq -r 'length' 2>/dev/null) +if [[ -z "$SERIES_COUNT" ]] || [[ "$SERIES_COUNT" -eq 0 ]]; then + error "API returned 0 series — aborting" + exit 1 +fi +info "$SERIES_COUNT series loaded" + +# ============================================================================================== +# ━━━ Classify ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_CLEAN Classifying ━━━" + +ANIME_NETWORKS_JSON=$(printf '%s\n' "${SONARR_ANIME_NETWORKS[@]}" | jq -R . | jq -s .) +KIDS_NETWORKS_JSON=$(printf '%s\n' "${SONARR_KIDS_NETWORKS[@]}" | jq -R . | jq -s .) + +RESULTS=$(echo "$SERIES_RESPONSE" | jq \ + --argjson animeNetworks "$ANIME_NETWORKS_JSON" \ + --argjson kidsNetworks "$KIDS_NETWORKS_JSON" \ + --arg animeRoot "${SONARR_ANIME_ROOT:-}" \ + --arg kidsRoot "${SONARR_KIDS_ROOT:-}" ' + def is_anime: + (any(.genres[]?; . == "Anime") + and (.originalLanguage.name == "Japanese" or (.network as $n | $animeNetworks | index($n) != null))) + or (any(.genres[]?; . == "Animation") and .originalLanguage.name == "Japanese") + or (.network as $n | $animeNetworks | index($n) != null); + def is_kids: + (is_anime | not) and ( + any(.genres[]?; . == "Children") + or (.certification as $c | ["TV-Y","TV-Y7"] | index($c) != null) + or (.network as $n | $kidsNetworks | index($n) != null) + ); + + map( + { + title, id, network, certification, rootFolderPath, + is_anime: is_anime, + is_kids: is_kids + } | + . + { + forward_anime_miss: (.is_anime and $animeRoot != "" and .rootFolderPath != $animeRoot), + forward_kids_miss: (.is_kids and $kidsRoot != "" and .rootFolderPath != $kidsRoot), + reverse_anime_leak: ((.is_anime | not) and $animeRoot != "" and .rootFolderPath == $animeRoot), + reverse_kids_leak: ((.is_anime | not) and (.is_kids | not) and $kidsRoot != "" and .rootFolderPath == $kidsRoot + and (.certification == "TV-MA" or .certification == "TV-14")) + } + ) +') + +FORWARD_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_anime_miss)] | length') +FORWARD_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.forward_kids_miss)] | length') +REVERSE_ANIME_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_anime_leak)] | length') +REVERSE_KIDS_COUNT=$(echo "$RESULTS" | jq '[.[] | select(.reverse_kids_leak)] | length') + +if [[ "$ENABLE_LOGGING" == true ]]; then + echo "$RESULTS" | jq -r '.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_anime_leak or .reverse_kids_leak) | + " [\(if .forward_anime_miss then "FORWARD-ANIME" elif .forward_kids_miss then "FORWARD-KIDS" elif .reverse_anime_leak then "REVERSE-ANIME" elif .reverse_kids_leak then "REVERSE-KIDS" else "?" end)] \(.title) (root: \(.rootFolderPath), network: \(.network // "n/a"), cert: \(.certification // "n/a"))"' +fi + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY SONARR CLASSIFICATION SUMMARY ━━━━━" +echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" +echo "$ICON_SYNC Series scanned: $SERIES_COUNT" +echo "$ICON_TRASH Forward — anime miss: $FORWARD_ANIME_COUNT (classified anime, outside ${SONARR_ANIME_ROOT:-})" +echo "$ICON_TRASH Forward — kids miss: $FORWARD_KIDS_COUNT (classified kids, outside ${SONARR_KIDS_ROOT:-})" +echo "$ICON_WARN Reverse — anime leak: $REVERSE_ANIME_COUNT (in ${SONARR_ANIME_ROOT:-}, no anime signal — review, may be deliberate style placement)" +echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${SONARR_KIDS_ROOT:-}, adult-rated content)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +[[ "$ENABLE_LOGGING" != true ]] && echo " (run with --log for the per-title list)" + +exit 0 diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index de1c2de..0ac0c18 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -436,6 +436,8 @@ HOSTN_SONARR_URL="http://localhost:8989" HOSTN_SONARR_API_KEY="" HOSTN_SONARR_TV_ROOT="/mnt/user/Tv_Shows" + HOSTN_SONARR_KIDS_ROOT="" # rootFolderPath literal, as reported by Sonarr API — leave blank if no dedicated kids root + HOSTN_SONARR_ANIME_ROOT="" # rootFolderPath literal, as reported by Sonarr API — leave blank if no dedicated anime root declare -A HOSTN_SONARR_PATH_MAP=( # ["/tv"]="/mnt/user/Tv_Shows" @@ -446,6 +448,8 @@ HOSTN_RADARR_API_KEY="" HOSTN_TMDB_API_KEY="" HOSTN_RADARR_MOVIES_ROOT="/mnt/user/Movies" + HOSTN_RADARR_KIDS_ROOT="" # rootFolderPath literal, as reported by Radarr API — leave blank if no dedicated kids root + HOSTN_RADARR_ANIME_ROOT="" # rootFolderPath literal, as reported by Radarr API — leave blank if no dedicated anime root declare -A HOSTN_RADARR_PATH_MAP=( # ["/movies"]="/mnt/user/Movies" diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index b4f3032..d11db6a 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1216,6 +1216,55 @@ RADARR_DROPPED_ADD_EXCLUSION=true # add removed movies to import exclusion list SONARR_DROPPED_ADD_EXCLUSION=true # add removed series to import exclusion list +# ━━━ Arr Content Classification (radarr/sonarr_classification_scan.sh) ━━━ +# +# Curated lists validated against real library data 2026-07-17 — every entry here was +# checked against the actual signal ratio (kids%/anime% of titles under that studio/network) +# before being added, specifically to avoid the false-positive traps found this session: +# general umbrella studios (Walt Disney Pictures, Paramount Pictures), general streaming +# platforms (Netflix, Disney+, Hulu), and networks that carry mixed content under one name +# (The WB, UPN) are deliberately excluded even though they produce plenty of kids/anime +# content — they also produce just as much non-kids/non-anime content under the same name. +# +# Anime always takes priority over kids when both signals could apply (explicit user rule). +# + RADARR_ANIME_STUDIOS=( + "Studio Ghibli" "ufotable" "Toei Company" "Toei Animation" "MAPPA" "Madhouse" + "Production I.G" "Wit Studio" "Bones" "Trigger" "Kyoto Animation" "CloverWorks" + "A-1 Pictures" "Sunrise" "J.C.Staff" "Shaft" "Studio Pierrot" "P.A. Works" + "David Production" "OLM Inc." + ) + RADARR_KIDS_STUDIOS=( + "DreamWorks Animation" "Pixar" "Disney Television Animation" + "Walt Disney Animation Studios" "DisneyToon Studios" "Walt Disney Productions" + "Walt Disney Feature Animation" "Warner Bros. Animation" "Illumination" + "Sony Pictures Animation" "Blue Sky Studios" "Mattel" "Rankin/Bass Productions" + "Mainframe Entertainment" "DreamWorks Animation Television" "ZAG Entertainment" + "Paramount Animation" "Pacific Data Images" "Cinesite Animation" + "Cartoon Network Studios" "Nickelodeon Animation Studio" "Nelvana" + "Aardman Animations" + ) + # Radarr's TMDb-vote/imdbId combo reliably flags bad metadata matches (proven live — + # caught 5 genuine junk entries: 2 fake X-Men "Wolverine" titles, Silent Hill: No Escape, + # Silent Hill 4: The Room, The Purge: Behind the Series — all hasFile=false, no imdbId, + # under this vote count). Do NOT reuse this threshold/approach for Sonarr — TheTVDB's + # ratings data is far sparser than TMDb's even for completely legitimate shows (confirmed + # live: "The Pussycat Dolls Present", a real 2007 MTV show, has ratings.votes=0), so the + # same heuristic there would flag real content for removal. + RADARR_JUNK_MIN_VOTES=15 + + SONARR_ANIME_NETWORKS=( + "Tokyo MX" "AT-X" "TV Tokyo" "Fuji TV" "Nippon TV" "TBS (JP)" "MBS" "TV Asahi" + "WOWOW" "NHK" "Animax" "tvk" "Teletama" "Kansai TV" "ABC (JP)" + ) + SONARR_KIDS_NETWORKS=( + "Cartoon Network" "Cartoon Network (CA)" "Cartoon Network (Canada)" "Nickelodeon" + "Nick Jr." "Nicktoons" "Disney Channel" "Disney Jr." "Disney XD" "Toon Disney" + "PBS" "PBS KIDS" "CBeebies" "CBBC" "YTV" "TVOkids" "Discovery Kids" + "Discovery Family" "Boomerang" "ABC Kids" "Teletoon" "Télétoon" "Milkshake!" + "Tiny Pop" "Sprout" "Universal Kids" "BabyTV" + ) + # ━━━ Arr Failed/Stalled Recovery ━━━ # Auto blocklist + re-search failed imports and stalled downloads. # Runs every 6 hours — schedule: 0 */6 * * * diff --git a/common.sh b/common.sh index 4682e8e..7c09e76 100755 --- a/common.sh +++ b/common.sh @@ -586,11 +586,15 @@ detect_hosts() { SONARR_URL_VAR="${MY_ID}_SONARR_URL"; SONARR_URL="${!SONARR_URL_VAR:-}" SONARR_API_KEY_VAR="${MY_ID}_SONARR_API_KEY"; SONARR_API_KEY="${!SONARR_API_KEY_VAR:-}" SONARR_TV_ROOT_VAR="${MY_ID}_SONARR_TV_ROOT"; SONARR_TV_ROOT="${!SONARR_TV_ROOT_VAR:-}" + SONARR_KIDS_ROOT_VAR="${MY_ID}_SONARR_KIDS_ROOT"; SONARR_KIDS_ROOT="${!SONARR_KIDS_ROOT_VAR:-}" + SONARR_ANIME_ROOT_VAR="${MY_ID}_SONARR_ANIME_ROOT"; SONARR_ANIME_ROOT="${!SONARR_ANIME_ROOT_VAR:-}" SONARR_RECOVERY_VAR="${MY_ID}_SONARR_RECOVERY"; SONARR_RECOVERY="${!SONARR_RECOVERY_VAR:-true}" RADARR_URL_VAR="${MY_ID}_RADARR_URL"; RADARR_URL="${!RADARR_URL_VAR:-}" RADARR_API_KEY_VAR="${MY_ID}_RADARR_API_KEY"; RADARR_API_KEY="${!RADARR_API_KEY_VAR:-}" RADARR_MOVIES_ROOT_VAR="${MY_ID}_RADARR_MOVIES_ROOT"; RADARR_MOVIES_ROOT="${!RADARR_MOVIES_ROOT_VAR:-}" + RADARR_KIDS_ROOT_VAR="${MY_ID}_RADARR_KIDS_ROOT"; RADARR_KIDS_ROOT="${!RADARR_KIDS_ROOT_VAR:-}" + RADARR_ANIME_ROOT_VAR="${MY_ID}_RADARR_ANIME_ROOT"; RADARR_ANIME_ROOT="${!RADARR_ANIME_ROOT_VAR:-}" RADARR_RECOVERY_VAR="${MY_ID}_RADARR_RECOVERY"; RADARR_RECOVERY="${!RADARR_RECOVERY_VAR:-true}" SLSKD_URL_VAR="${MY_ID}_SLSKD_URL"; SLSKD_URL="${!SLSKD_URL_VAR:-}"