feat: arr-native distributed media sync + config architecture fixes
Arr sync (new): - Media/arr_sync.sh — full mesh bidirectional sync across all HOST* nodes - Lidarr (MusicBrainz), Sonarr (TVDB), Radarr (TMDB) all handled in one script - Remote API keys read live from config.xml via SSH — never stored in conf files - Shared blocklist (DATA_DIR/arr_sync_blocklist.tsv) merged from all nodes at runtime - Graceful skip if arr not configured locally or not reachable on a remote node - --blocklist-add / --blocklist-remove / --blocklist-list management flags - daily_sync_maintenance.sh — arr sync runs as explicit phase before rsync - partnership_onboard.sh — Step 3 bootstraps merged library on both sides at onboard - master.conf — ARR_SYNC_* config block, DOCKER_APPDATA_BASE Rsync / cleanup: - DEFAULT_RSYNC_OPTS — removed --delete; arr_cleanup.sh owns orphan enforcement - lidarr_cleanup.sh — removed HOST1-only guard; runs on any node with Lidarr configured Config architecture: - HOST1/HOST2 hostnames moved from master_host*.conf → master.conf (not credentials) - Sparse checkout now works correctly: each server only needs its own host conf - detect_hosts() still resolves MY_ID + REMOTE_ID via master.conf hostname values Bug fix: - common.sh line 493 — watchdog toggle eval had broken quoting; all SYS_WATCHDOG_CHECK_* globals were silently set to empty instead of their configured values Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
009820e981
commit
b65f572367
@@ -0,0 +1,232 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================================
|
||||
# ============================= Sonarr — TVDB Removed ==========================================
|
||||
# ==============================================================================================
|
||||
# Removes series from Sonarr that have been dropped from TVDB.
|
||||
# Sonarr marks these with status="deleted" — they generate system health errors and
|
||||
# can never be monitored or downloaded.
|
||||
#
|
||||
# ── WHAT IT DOES ──────────────────────────────────────────────────────────────────────────────
|
||||
# Queries Sonarr API for series with status="deleted" (TVDB removal marker)
|
||||
# Reports each found 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)
|
||||
#
|
||||
# ── SAFE DEFAULTS ─────────────────────────────────────────────────────────────────────────────
|
||||
# Files are NOT deleted by default — use --delete-files to also remove from disk
|
||||
# Import exclusion added by default — prevents Sonarr re-adding dropped series
|
||||
# Per-deletion output always visible — deletions are never silently swallowed
|
||||
#
|
||||
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
|
||||
# Sonarr runs on HOST1 only. detect_hosts() sets SONARR_URL — if empty (HOST2)
|
||||
# the script exits cleanly.
|
||||
#
|
||||
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
|
||||
# SONARR_DROPPED_ADD_EXCLUSION — add removed series to import exclusion (default: true)
|
||||
#
|
||||
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
|
||||
# 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 ━━━
|
||||
# ==============================================================================================
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR 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
|
||||
log "Sonarr not configured for $MY_ID — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ADD_EXCLUSION="${SONARR_DROPPED_ADD_EXCLUSION:-true}"
|
||||
|
||||
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
|
||||
|
||||
SERIES=$(curl -sf --connect-timeout 5 --max-time 30 \
|
||||
"$SONARR_URL/api/v3/series?apikey=$SONARR_API_KEY" 2>/dev/null)
|
||||
|
||||
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
|
||||
log "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
|
||||
echo "$ICON_WARN $title ($year) [tvdbid $tvdb_id] — $episode_file_count episode files${SIZE_HUMAN:+, $SIZE_HUMAN}"
|
||||
else
|
||||
echo "$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
|
||||
log " 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
|
||||
log "$ICON_DONE Status: done ✅"
|
||||
else
|
||||
warn "Status: ${#FAILED[@]} removal(s) failed"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
|
||||
exit 0
|
||||
Reference in New Issue
Block a user