diff --git a/Arrs_Stack/sonarr_classification_scan.sh b/Arrs_Stack/sonarr_classification_scan.sh index a95acf8..25f2cd5 100755 --- a/Arrs_Stack/sonarr_classification_scan.sh +++ b/Arrs_Stack/sonarr_classification_scan.sh @@ -15,9 +15,10 @@ # 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. +# 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 @@ -56,16 +57,37 @@ # 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. +# 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 — a rapid-fire batch of Sonarr moves is exactly +# what raced Sonarr's own file-move worker earlier this session doing this by hand (2 series +# reported "success" while their files sat at an intermediate path). A short sleep plus a +# real re-fetch-and-check after every single move catches that here before it can compound. # # ============================================================================================== # 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. +# of library size. Refreshed after --move writes so no other script reads stale data. # # ============================================================================================== # CONFIGURATION @@ -73,9 +95,10 @@ # # 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. +# 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 @@ -88,6 +111,7 @@ # 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 # # ============================================================================================== @@ -95,6 +119,14 @@ 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 "$@" # ============================================================================================== @@ -126,10 +158,12 @@ if [[ "$SHOW_STATUS" == true ]]; then 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 @@ -188,6 +222,7 @@ RESULTS=$(echo "$SERIES_RESPONSE" | jq \ map( { title, id, network, certification, rootFolderPath, + episodeFileCount: (.statistics.episodeFileCount // 0), is_anime: is_anime, is_kids: is_kids } | @@ -225,4 +260,156 @@ echo "$ICON_WARN Reverse — kids leak: $REVERSE_KIDS_COUNT (in ${SONARR_K 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 + + 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 diff --git a/Deployment/host.conf.template b/Deployment/host.conf.template index b9680a0..38cac4a 100644 --- a/Deployment/host.conf.template +++ b/Deployment/host.conf.template @@ -436,6 +436,7 @@ HOSTN_SONARR_URL="http://localhost:8989" HOSTN_SONARR_API_KEY="" HOSTN_SONARR_TV_ROOT="/mnt/user/Tv_Shows" + HOSTN_SONARR_GENERAL_ROOT="" # rootFolderPath literal for the general root (e.g. "/tv") — target for reverse-kids-leak moves; leave blank to disable 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 diff --git a/common.sh b/common.sh index 1534b37..5e71bd4 100755 --- a/common.sh +++ b/common.sh @@ -586,6 +586,7 @@ 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_GENERAL_ROOT_VAR="${MY_ID}_SONARR_GENERAL_ROOT"; SONARR_GENERAL_ROOT="${!SONARR_GENERAL_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}"