#!/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 by default. 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. Pass --move to actually act (see MOVE MODE below) — nothing writes to Sonarr unless # that flag is given. # # ============================================================================================== # 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. --remove-junk from the Radarr script has # no Sonarr equivalent for the same reason. # # ============================================================================================== # MOVE MODE (--move) # ============================================================================================== # # Acts on FORWARD misplacements (classified anime/kids, sitting in the wrong root) and on # REVERSE-KIDS leaks (adult certification sitting in the kids root — moved back to # SONARR_GENERAL_ROOT). Does NOT act on REVERSE-ANIME leaks — those are genuine judgment # calls, since deliberate style placements (Castlevania-type Western/Chinese animation # grouped with anime by choice) legitimately live in the anime root without matching the # anime signal. # # episodeFileCount is Sonarr's equivalent of Radarr's hasFile — a series can have 0 files # (fully monitored, nothing downloaded) even while correctly classified. Those get their # rootFolderPath/path corrected and an immediate SeriesSearch triggered rather than a file # move (mirrors radarr_classification_scan.sh's handling of hasFile=false movies). # # One series at a time, verified after each. moveFiles=true flips the DB (rootFolderPath/ # episodeFileCount) instantly, but the physical move is a separate async MoveSeries command # Sonarr drains one at a time internally — DB fields alone can report "moved" while the real # files are still sitting at the old path behind other queued moves (confirmed live: "Full # House" reported episodeFileCount:192 at the new path via API while the actual 75GB/192 # files hadn't moved yet). Each move polls its own MoveSeries command to "completed" before # the DB-field check runs, so a batch can't compound the race the way a bare sleep-and-check did. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # 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. Refreshed after --move writes so no other script reads stale data. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # SONARR_URL / SONARR_API_KEY / SONARR_TV_ROOT — existing, aliased by detect_hosts() # SONARR_GENERAL_ROOT / SONARR_KIDS_ROOT / SONARR_ANIME_ROOT — rootFolderPath literals as # reported by the API (e.g. "/tv", "/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 # sonarr_classification_scan.sh --move — act on forward misplacements + reverse-kids-leak # # ============================================================================================== 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 "${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 General root: ${SONARR_GENERAL_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 "$ICON_GEAR Move mode: $MOVE_MODE" 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 .) # Not just the US TV-MA/TV-14 tiers — non-US certification scales use different labels for the # same "clearly adult" tier (confirmed live: "Tomb Raider: The Legend of Lara Croft" is "16+"). ADULT_CERT_JSON='["TV-MA","TV-14","MA15+","16","16+","18","15","14"]' RESULTS=$(echo "$SERIES_RESPONSE" | jq \ --argjson animeNetworks "$ANIME_NETWORKS_JSON" \ --argjson kidsNetworks "$KIDS_NETWORKS_JSON" \ --argjson adultCert "$ADULT_CERT_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, episodeFileCount: (.statistics.episodeFileCount // 0), 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 as $c | $adultCert | index($c) != null)) } ) ') 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)" # ============================================================================================== # ━━━ Move Mode ━━━ # ============================================================================================== # See MOVE MODE in the header for scope (forward + reverse-kids-leak, not reverse-anime-leak). if [[ "$MOVE_MODE" == true ]]; then echo "" echo "━━━ $ICON_SYNC Move Mode ━━━" acquire_lock "wait" trap "_release_all_locks" EXIT build_arr_path_map "SONARR" MOVE_TARGETS=$(echo "$RESULTS" | jq -c '[.[] | select(.forward_anime_miss or .forward_kids_miss or .reverse_kids_leak)]') 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 series — 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_files_count=$(echo "$item" | jq -r '.episodeFileCount') if [[ "$is_anime_flag" == "true" ]]; then target_root="$SONARR_ANIME_ROOT" elif [[ "$is_forward_kids" == "true" ]]; then target_root="$SONARR_KIDS_ROOT" else target_root="$SONARR_GENERAL_ROOT" fi if [[ -z "$target_root" ]]; then error " ✗ $title — target root not configured (SONARR_GENERAL_ROOT blank), skipping" (( FAILED++ )) continue fi # RESULTS only carries the reduced report fields — Sonarr's PUT expects the complete # resource representation, so fetch a fresh full series record to modify and send back. full_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr") if [[ -z "$full_series" ]]; then error " ✗ $title — could not fetch full series record, skipping" (( FAILED++ )) continue fi old_path=$(echo "$full_series" | 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 — this is exactly the self-inflicted bug hit doing the # Fate/Zero and Fate/Stay Night moves by hand earlier this session. if [[ "$folder_name" == *"/"* ]]; then error " ✗ $title — folder name contains '/', skipping (needs manual handling)" (( FAILED++ )) continue fi new_path="${target_root}/${folder_name}" if [[ "$had_files_count" -gt 0 ]]; then info " → $title: $old_path → $new_path (moving $had_files_count episode file(s))" move_qs="?moveFiles=true" else info " → $title: $old_path → $new_path (no files — relocating + search)" move_qs="" fi updated_series=$(echo "$full_series" | 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: $SONARR_API_KEY" \ -H "Content-Type: application/json" \ -d "$updated_series" \ "${SONARR_URL}/api/v3/series/${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/episodeFileCount in the DB instantly, but the actual # physical move is a separate async MoveSeries command that Sonarr drains one at a time # internally — confirmed live: "Full House" showed episodeFileCount:192 at the new path via # API while the real 75GB/192 files were still sitting at the old path, MoveSeries queued # behind ~20 others. The DB-field check below cannot see that: poll the actual command to # completion first, or a batch run can report every series "moved" while most are still # mid-drain. 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: $SONARR_API_KEY" \ "${SONARR_URL}/api/v3/command" 2>/dev/null | \ jq -r --argjson sid "$id" \ '[.[] | select(.name == "MoveSeries" and .body.seriesId == $sid)] | 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 MoveSeries command — stopping (review before re-running)" (( FAILED++ )) break fi info " → $title: MoveSeries command $move_cmd_id queued, waiting for completion..." move_status="" move_polled=0 while [[ "$move_polled" -lt "$SONARR_MOVE_POLL_TIMEOUT" ]]; do move_status=$(curl -sf --max-time 10 -H "X-Api-Key: $SONARR_API_KEY" \ "${SONARR_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 — MoveSeries command $move_cmd_id ended as '${move_status:-timed out after ${SONARR_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. # This exact check is what caught the earlier race condition doing this by hand: two # series reported "success" while episodeFileCount had silently dropped to 0. verify_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series/$id" "Sonarr") verify_root=$(echo "$verify_series" | jq -r '.rootFolderPath') verify_filecount=$(echo "$verify_series" | jq -r '.statistics.episodeFileCount // 0') if [[ "$verify_root" != "$target_root" ]]; then error " ✗ $title — verification failed (root: $verify_root) — stopping" (( FAILED++ )) break fi if [[ "$had_files_count" -gt 0 ]]; then if [[ "$verify_filecount" -eq "$had_files_count" ]]; then echo " $ICON_SUCCESS $title — moved and verified ($verify_filecount files)" (( MOVED++ )) else error " ✗ $title — verification failed (root updated but episode count $verify_filecount != expected $had_files_count) — stopping" (( FAILED++ )) break fi else search_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \ --max-time 30 \ -H "X-Api-Key: $SONARR_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"name\":\"SeriesSearch\",\"seriesId\":${id}}" \ "${SONARR_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. Refresh it now # rather than leave that window open for every other script reading this cache. if [[ "$(( MOVED + RELOCATED_SEARCH ))" -gt 0 ]]; then info "Refreshing shared tracked-data cache..." fresh_series=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series" "Sonarr") [[ -n "$fresh_series" ]] && arr_cache_write "sonarr" "$fresh_series" fi echo "" echo "━━━━━ $ICON_SUMMARY MOVE SUMMARY ━━━━━" echo "$ICON_SUCCESS Moved (files relocated): $MOVED" echo "$ICON_SUCCESS Relocated + search triggered: $RELOCATED_SEARCH" echo "$ICON_ERROR Failed: $FAILED" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" fi exit 0