#!/bin/bash # ============================================================================================== # ============================= Sonarr — TVDB Removed ========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Remove series from Sonarr that TVDB has dropped. Sonarr marks these with # status="deleted" — they generate health errors and can never be monitored # or downloaded. # # Cache-first series list (2026-07-17) — comes from the shared tracked-data cache via # arr_get_tracked_data(), fresh (kept warm every 30min by arr_cache_prefill.sh), live fetch # as fallback. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Queries Sonarr API for series with status="deleted" (TVDB removal marker) # Reports each entry with file count and total size # Removes the series record from Sonarr # Optionally deletes associated files (disabled by default) # Optionally adds to Sonarr's import exclusion list (default: true — prevents re-add) # # Files are NOT deleted by default — use --delete-files to also remove from disk. # Per-deletion output is always visible — deletions are never silently swallowed. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Health Error Hygiene # status="deleted" series can never be monitored or downloaded — they only # generate persistent health errors. Removing them is maintenance, not # data loss: most have no associated files. # # Conservative File Handling # Files are not deleted by default. A TVDB-removed series may still have # episodes on disk that the user wants to keep. The --delete-files flag # is an explicit opt-in, not the default path. # # Exclusion List Prevents Re-add # Removed entries are added to Sonarr's import exclusion list by default. # Without this, the same deleted series can be re-added by lists or searches # and immediately generate the same health error again. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # detect_hosts() — exits cleanly if SONARR_URL empty (Sonarr not on this host) # API pre-flight — verifies Sonarr reachable before querying # Dry-run mode — full preview without removing anything # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master.conf # # SONARR_DROPPED_ADD_EXCLUSION — add removed series to import exclusion (default: true) # # host*.conf # # HOST1_SONARR_URL / HOST1_SONARR_API_KEY # Aliased by detect_hosts() — script uses SONARR_URL / SONARR_API_KEY # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # sonarr_tvdb_removed.sh — remove records, keep files, add exclusion # sonarr_tvdb_removed.sh --delete-files — also delete files from disk # sonarr_tvdb_removed.sh --dry-run — preview without removing anything # sonarr_tvdb_removed.sh --log — verbose output # sonarr_tvdb_removed.sh --status — show config and exit # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" # ── Parse --delete-files before standard parse_args ─────────────────────────────────────────── DELETE_FILES=false FILTERED_ARGS=() for arg in "$@"; do if [[ "$arg" == "--delete-files" ]]; then DELETE_FILES=true else FILTERED_ARGS+=("$arg") fi done parse_args "${FILTERED_ARGS[@]}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi if ! command -v curl >/dev/null 2>&1; then error "curl not found" exit 1 fi if ! command -v jq >/dev/null 2>&1; then error "jq not found" exit 1 fi acquire_lock detect_hosts if [[ -z "$SONARR_URL" ]]; then echo "Sonarr not configured for $MY_ID — skipping" exit 0 fi ADD_EXCLUSION="${SONARR_DROPPED_ADD_EXCLUSION:-true}" log "$ICON_GEAR Config: url=${SONARR_URL} add-exclusion=${ADD_EXCLUSION} delete-files=${DELETE_FILES:-false}" echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL" [[ "$DELETE_FILES" == true ]] && warn "DELETE FILES MODE — files will be removed from disk" [[ "$DELETE_FILES" == false ]] && echo " Files: records only (use --delete-files to also remove from disk)" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== 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 Add exclusion: $ADD_EXCLUSION" echo "$ICON_GEAR Delete files: $DELETE_FILES" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Query Sonarr ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Querying Sonarr ━━━" if ! curl -sf --connect-timeout 5 --max-time 10 \ "$SONARR_URL/api/v3/system/status?apikey=$SONARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then error "Sonarr API unreachable at $SONARR_URL" exit 1 fi # Cache-first — arr_get_tracked_data() serves the shared cache when it's fresh (kept current # every 30min by arr_cache_prefill.sh in CRITICAL_MAINTENANCE_SCRIPTS), falls back to a live # fetch when it's stale, and waits out an active rescan before either. SERIES=$(arr_get_tracked_data "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") if [[ -z "$SERIES" || "$SERIES" == "null" ]]; then error "Sonarr series API returned empty" exit 1 fi TOTAL=$(echo "$SERIES" | jq '. | length') DROPPED=$(echo "$SERIES" | jq '[.[] | select(.status == "deleted")] | length') echo " $TOTAL series total — $DROPPED dropped from TVDB" if [[ "$DROPPED" -eq 0 ]]; then echo "No TVDB-removed series found — nothing to do" echo "" echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_DONE Status: nothing to remove" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Remove Dropped Series ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_TRASH Remove TVDB-Dropped Series ━━━" START=$(date +%s) REMOVED=() FAILED=() FILES_DELETED=0 FILES_SKIPPED=0 while IFS=$'\t' read -r id title year tvdb_id episode_file_count size_on_disk; do [[ -z "$id" ]] && continue SIZE_HUMAN="" if [[ "$size_on_disk" -gt 0 ]]; then SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $size_on_disk / 1073741824}") fi if [[ "$episode_file_count" -gt 0 ]]; then log "$ICON_WARN $title ($year) [tvdbid $tvdb_id] — $episode_file_count episode files${SIZE_HUMAN:+, $SIZE_HUMAN}" else log "$ICON_TRASH $title ($year) [tvdbid $tvdb_id] — no files" fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would remove: $title" [[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]] && \ warn "DRY RUN — would delete $episode_file_count file(s)${SIZE_HUMAN:+, $SIZE_HUMAN}" REMOVED+=("$title") continue fi DELETE_PARAM="false" if [[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]]; then DELETE_PARAM="true" fi RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \ "$SONARR_URL/api/v3/series/${id}?deleteFiles=${DELETE_PARAM}&addImportListExclusion=${ADD_EXCLUSION}&apikey=$SONARR_API_KEY" \ 2>/dev/null) CURL_EXIT=$? if [[ "$CURL_EXIT" -eq 0 ]]; then echo " Removed from Sonarr ✅" REMOVED+=("$title") if [[ "$DELETE_PARAM" == "true" ]]; then (( FILES_DELETED++ )) elif [[ "$episode_file_count" -gt 0 ]]; then (( FILES_SKIPPED++ )) fi else warn " Failed to remove $title (curl exit $CURL_EXIT)" FAILED+=("$title") fi done < <(echo "$SERIES" | jq -r ' .[] | select(.status == "deleted") | [ (.id | tostring), .title, (.year | tostring), (.tvdbId | tostring), (if .episodeFileCount? then (.episodeFileCount | tostring) else "0" end), (if .sizeOnDisk? then (.sizeOnDisk | tostring) else "0" end) ] | @tsv ') END=$(date +%s) # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" echo "$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED" [[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}" [[ "$FILES_DELETED" -gt 0 ]] && echo "$ICON_TRASH Files deleted: $FILES_DELETED series worth" [[ "$FILES_SKIPPED" -gt 0 ]] && echo "$ICON_WARN Files kept: $FILES_SKIPPED series (had files — use --delete-files to remove)" [[ "$ADD_EXCLUSION" == "true" ]] && echo "$ICON_GEAR Import exclusion added for removed entries" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes made" elif [[ ${#FAILED[@]} -eq 0 ]]; then echo "$ICON_DONE Status: done ✅" else warn "Status: ${#FAILED[@]} removal(s) failed" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ ${#FAILED[@]} -gt 0 ]] && exit 1 exit 0