#!/bin/bash # ============================================================================================== # ================================= ARR Sync =================================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Full-mesh arr library sync across all nodes — Lidarr, Sonarr, and Radarr. # Every node syncs with every other, union model, no hierarchy. Run before # rsync in the weekly sync window: once arrs agree on what to track, rsync # spreads the actual files. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Full mesh: every node syncs with every other — no primary, no hierarchy. # Union model: if any node tracks an item, all nodes get it (unless blocklisted). # Convergence: any node can add content; after one full cycle all nodes agree. # Upgrade-aware: a file upgrade on one node → arr tracks new path → rsync spreads # it → arr_cleanup removes old path on all nodes (arr no longer tracks it). # # Node discovery: reads HOST* vars from master.conf. Add HOST3= and it joins the # sync automatically — no script changes needed for a new node. # # Graceful skip: arr not configured locally → skip cleanly. Arr not reachable on # a remote → skip that node for that arr type, continue with others. # # What gets synced — library items keyed on stable external IDs: # Lidarr — MusicBrainz artist ID (foreignArtistId) # Sonarr — TVDB series ID (tvdbId) # Radarr — TMDB movie ID (tmdbId) # When adding to a remote, that node's own quality profile, metadata profile, # and root folder path are used — settings are never copied from source. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Remote API Keys Never Stored # SSHes to each remote node and reads the key directly from that arr's # config.xml in its appdata directory. Only the API response (JSON) is # returned — the key never leaves the remote node. Self-maintaining: key # regeneration on the remote is picked up automatically next run. # # Blocklist TSV # ARR_SYNC_BLOCKLIST in DATA_DIR tombstones IDs that must never be re-added # anywhere. Read from ALL nodes via SSH at run start — immediate effect with # no rsync delay. # # --blocklist-add does three things atomically: # 1. Writes the TSV tombstone entry (prevents future re-adds by arr_sync) # 2. Deletes the item from the local arr API (deleteFiles=false) # 3. SSHes each remote node and deletes from their arr API (deleteFiles=false) # Files become orphans on all nodes — arr_cleanup removes them on next run. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # acquire_lock — prevents two sync instances running simultaneously # ARR_SYNC_ENABLED — global gate, exits cleanly when false # SSH connect timeout — ARR_SYNC_CONNECT_TIMEOUT — does not hang on unreachable node # API call timeout — ARR_SYNC_API_TIMEOUT — does not hang on slow arr # Graceful skip — unreachable node/arr → skip and continue, never abort # Blocklist gate — item in blocklist → never added to any node # Silent by default — only additions produce output, clean runs stay silent # # ============================================================================================== # STATE FILES # ============================================================================================== # # ARR_SYNC_BLOCKLIST — TSV file in DATA_DIR (default: DATA_DIR/arr_sync_blocklist.tsv) # Columns: arr_type, id, reason, date_added # Read from all nodes via SSH at the start of each run. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST*_LIDARR_URL / HOST*_LIDARR_API_KEY — local Lidarr (aliased by detect_hosts) # HOST*_SONARR_URL / HOST*_SONARR_API_KEY — local Sonarr # HOST*_RADARR_URL / HOST*_RADARR_API_KEY — local Radarr # # master.conf # # ARR_SYNC_ENABLED — global on/off toggle (default: true) # ARR_SYNC_BLOCKLIST — path to TSV blocklist file # ARR_SYNC_CONNECT_TIMEOUT — SSH connect timeout in seconds (default: 10) # ARR_SYNC_API_TIMEOUT — curl API call timeout in seconds (default: 60) # DOCKER_APPDATA_BASE — base path for arr appdata dirs (default: /mnt/user/appdata) # ARR_SYNC_LIDARR_PORT — Lidarr port on all nodes (default: 8686) # ARR_SYNC_SONARR_PORT — Sonarr port on all nodes (default: 8989) # ARR_SYNC_RADARR_PORT — Radarr port on all nodes (default: 7878) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # arr_sync.sh — sync all arr types, all nodes # arr_sync.sh --dry-run — preview only, no changes # arr_sync.sh --log — verbose output # arr_sync.sh --status — show config and exit # # Blocklist management: # arr_sync.sh --blocklist-add lidarr "reason" — remove from all arrs + tombstone # arr_sync.sh --blocklist-add sonarr "reason" — remove from all arrs + tombstone # arr_sync.sh --blocklist-add radarr "reason" — remove from all arrs + tombstone # arr_sync.sh --blocklist-remove lidarr — un-tombstone (does NOT re-add) # arr_sync.sh --blocklist-list — show all blocklisted IDs # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" # ── Blocklist flag pre-processing ───────────────────────────────────────────────────────────── # Handle before parse_args — these are action flags, not standard options BLOCKLIST_ACTION="" BLOCKLIST_ARR="" BLOCKLIST_ID="" BLOCKLIST_REASON="" FILTERED_ARGS=() _skip_next=false for _arg in "$@"; do if [[ "$_skip_next" == true ]]; then _skip_next=false; continue; fi case "$_arg" in --blocklist-add) BLOCKLIST_ACTION="add" ;; --blocklist-remove) BLOCKLIST_ACTION="remove" ;; --blocklist-list) BLOCKLIST_ACTION="list" ;; *) FILTERED_ARGS+=("$_arg") ;; esac done unset _arg _skip_next parse_args "${FILTERED_ARGS[@]}" # Consume blocklist positional args from remaining FILTERED_ARGS # --blocklist-add lidarr "reason" # --blocklist-remove lidarr if [[ -n "$BLOCKLIST_ACTION" ]] && [[ "$BLOCKLIST_ACTION" != "list" ]]; then for _a in "${FILTERED_ARGS[@]}"; do [[ "$_a" == --* ]] && continue if [[ -z "$BLOCKLIST_ARR" ]]; then BLOCKLIST_ARR="$_a" elif [[ -z "$BLOCKLIST_ID" ]]; then BLOCKLIST_ID="$_a" elif [[ -z "$BLOCKLIST_REASON" ]]; then BLOCKLIST_REASON="$_a" fi done unset _a fi # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi acquire_lock for _tool in curl jq; do if ! command -v "$_tool" >/dev/null 2>&1; then error "$_tool not found — required for arr API calls" exit 1 fi done unset _tool detect_hosts # ── Runtime config with defaults ────────────────────────────────────────────────────────────── ARR_SYNC_ENABLED="${ARR_SYNC_ENABLED:-true}" ARR_SYNC_BLOCKLIST="${ARR_SYNC_BLOCKLIST:-${DATA_DIR}/arr_sync_blocklist.tsv}" ARR_SYNC_CONNECT_TIMEOUT="${ARR_SYNC_CONNECT_TIMEOUT:-10}" ARR_SYNC_API_TIMEOUT="${ARR_SYNC_API_TIMEOUT:-60}" DOCKER_APPDATA_BASE="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}" ARR_SYNC_LIDARR_PORT="${ARR_SYNC_LIDARR_PORT:-8686}" ARR_SYNC_SONARR_PORT="${ARR_SYNC_SONARR_PORT:-8989}" ARR_SYNC_RADARR_PORT="${ARR_SYNC_RADARR_PORT:-7878}" if [[ "$ARR_SYNC_ENABLED" != "true" ]]; then log "ARR_SYNC_ENABLED=false — exiting" exit 0 fi # ── Arr type definitions ─────────────────────────────────────────────────────────────────────── # Each arr type maps to its port, API version, endpoint, stable ID field, and display name field declare -A _PORT=([lidarr]="$ARR_SYNC_LIDARR_PORT" [sonarr]="$ARR_SYNC_SONARR_PORT" [radarr]="$ARR_SYNC_RADARR_PORT") declare -A _VER=( [lidarr]="v1" [sonarr]="v3" [radarr]="v3") declare -A _EP=( [lidarr]="artist" [sonarr]="series" [radarr]="movie") declare -A _ID=( [lidarr]="foreignArtistId" [sonarr]="tvdbId" [radarr]="tmdbId") declare -A _NAME=([lidarr]="artistName" [sonarr]="title" [radarr]="title") # ID type: "string" for MusicBrainz UUID, "int" for TVDB/TMDB numeric IDs declare -A _ID_TYPE=([lidarr]="string" [sonarr]="int" [radarr]="int") ARR_TYPES=(lidarr sonarr radarr) # ── Remote node discovery ────────────────────────────────────────────────────────────────────── REMOTE_NODES=() for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do [[ "$_hv" == "$MY_ID" ]] && continue [[ -z "${!_hv:-}" ]] && continue REMOTE_NODES+=("$_hv") done unset _hv if [[ "${#REMOTE_NODES[@]}" -eq 0 ]]; then warn "No remote nodes defined in master.conf — nothing to sync" exit 0 fi echo " Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo " Remote nodes: ${REMOTE_NODES[*]}" echo " Blocklist: $ARR_SYNC_BLOCKLIST" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # ============================================================================================== # ── BLOCKLIST ───────────────────────────────────────────────────────────────────────────────── # ============================================================================================== declare -A BLOCKLIST_MAP # key: "arr_type:stable_id" → display name _load_blocklist() { BLOCKLIST_MAP=() local count=0 _parse_blocklist_lines() { while IFS=$'\t' read -r arr_type stable_id display_name rest; do [[ -z "$arr_type" ]] || [[ "$arr_type" == \#* ]] && continue BLOCKLIST_MAP["${arr_type}:${stable_id}"]="$display_name" (( count++ )) done } # Local blocklist [[ -f "$ARR_SYNC_BLOCKLIST" ]] && _parse_blocklist_lines < "$ARR_SYNC_BLOCKLIST" # Remote blocklists — read via SSH so tombstones are effective immediately for _node_id in "${REMOTE_NODES[@]}"; do local _node_ip _node_ip=$(_resolve_node_ip "$_node_id") || continue local _remote_lines _remote_lines=$(ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$_node_ip" "cat '$ARR_SYNC_BLOCKLIST' 2>/dev/null" 2>/dev/null) || continue _parse_blocklist_lines <<< "$_remote_lines" done unset _node_id _node_ip _remote_lines log "Loaded blocklist: $count entries from $(( ${#REMOTE_NODES[@]} + 1 )) nodes" unset -f _parse_blocklist_lines } _is_blocklisted() { [[ -n "${BLOCKLIST_MAP["${1}:${2}"]:-}" ]] } _blocklist_add() { local arr_type="$1" stable_id="$2" display_name="$3" reason="${4:-manually excluded}" mkdir -p "$(dirname "$ARR_SYNC_BLOCKLIST")" if ! grep -qP "^${arr_type}\t${stable_id}\t" "$ARR_SYNC_BLOCKLIST" 2>/dev/null; then printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ "$arr_type" "$stable_id" "$display_name" \ "$MY_ID" "$(date -Iseconds)" "$reason" \ >> "$ARR_SYNC_BLOCKLIST" BLOCKLIST_MAP["${arr_type}:${stable_id}"]="$display_name" log "Blocklisted: [$arr_type] $display_name ($stable_id)" else log "Already blocklisted: [$arr_type] $stable_id" fi } _blocklist_remove() { local arr_type="$1" stable_id="$2" if [[ -f "$ARR_SYNC_BLOCKLIST" ]]; then local tmp tmp=$(mktemp) grep -vP "^${arr_type}\t${stable_id}\t" "$ARR_SYNC_BLOCKLIST" > "$tmp" && \ mv "$tmp" "$ARR_SYNC_BLOCKLIST" || rm -f "$tmp" unset "BLOCKLIST_MAP[${arr_type}:${stable_id}]" log "Removed from blocklist: [$arr_type] $stable_id" fi } # Look up item in local arr by stable_id — returns "internal_id\tdisplay_name" or empty _lookup_local_item() { local url="$1" api_key="$2" api_ver="$3" endpoint="$4" local id_field="$5" id_type="$6" name_field="$7" stable_id="$8" local raw select_expr raw=$(curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \ -H "X-Api-Key: $api_key" \ "${url}/api/${api_ver}/${endpoint}" 2>/dev/null) [[ -z "$raw" ]] && return 1 if [[ "$id_type" == "string" ]]; then select_expr=".[] | select(.${id_field} == \"${stable_id}\")" else select_expr=".[] | select(.${id_field} == ${stable_id})" fi echo "$raw" | jq -r "${select_expr} | [(.id | tostring), .${name_field}] | @tsv" 2>/dev/null | head -1 } # Delete item from local arr by internal integer id — returns HTTP status code _delete_local_item() { local url="$1" api_key="$2" api_ver="$3" endpoint="$4" internal_id="$5" curl -sf -o /dev/null -w '%{http_code}' -X DELETE \ -H "X-Api-Key: $api_key" \ "${url}/api/${api_ver}/${endpoint}/${internal_id}?deleteFiles=false" 2>/dev/null } # Delete item from remote arr by stable_id via SSH. # Outputs: HTTP code on success | "not_found" if item absent | empty on SSH/API failure. # deleteFiles=false — files become orphans for arr_cleanup to handle with its safety checks. _delete_remote_item() { local node_id="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5" local id_field="$6" id_type="$7" stable_id="$8" local node_name="${!node_id}" local node_ip node_ip=$(resolve_tailscale_ip "$node_name") || return 1 local select_expr if [[ "$id_type" == "string" ]]; then select_expr=".[] | select(.${id_field} == \"${stable_id}\") | .id" else select_expr=".[] | select(.${id_field} == ${stable_id}) | .id" fi ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$node_ip" bash </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${config_xml}' 2>/dev/null) [[ -z "\$KEY" ]] && exit 1 LIBRARY=\$(curl -sf --max-time ${ARR_SYNC_API_TIMEOUT} \ -H "X-Api-Key: \$KEY" \ "http://localhost:${port}/api/${api_ver}/${endpoint}" 2>/dev/null) [[ -z "\$LIBRARY" ]] && exit 1 INTERNAL_ID=\$(echo "\$LIBRARY" | jq -r '${select_expr}' 2>/dev/null | head -1) [[ -z "\$INTERNAL_ID" ]] && echo "not_found" && exit 0 curl -sf -o /dev/null -w '%{http_code}' -X DELETE \ -H "X-Api-Key: \$KEY" \ "http://localhost:${port}/api/${api_ver}/${endpoint}/\${INTERNAL_ID}?deleteFiles=false" REMOTE } # ── Blocklist management mode ────────────────────────────────────────────────────────────────── if [[ -n "$BLOCKLIST_ACTION" ]]; then case "$BLOCKLIST_ACTION" in list) echo "" echo "━━━━━ ARR SYNC BLOCKLIST ━━━━━" if [[ ! -f "$ARR_SYNC_BLOCKLIST" ]] || [[ ! -s "$ARR_SYNC_BLOCKLIST" ]]; then echo " (empty)" else echo "" printf '%-8s %-40s %-30s %s\n' "Arr" "Stable ID" "Name" "Reason" printf '%-8s %-40s %-30s %s\n' "---" "---------" "----" "------" while IFS=$'\t' read -r arr_type stable_id display_name tombstoned_by ts reason; do [[ -z "$arr_type" ]] || [[ "$arr_type" == \#* ]] && continue printf '%-8s %-40s %-30s %s\n' "$arr_type" "$stable_id" "$display_name" "$reason" done < "$ARR_SYNC_BLOCKLIST" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 ;; add) if [[ -z "$BLOCKLIST_ARR" ]] || [[ -z "$BLOCKLIST_ID" ]]; then error "Usage: arr_sync.sh --blocklist-add [reason]" error " arr_type: lidarr | sonarr | radarr" exit 1 fi if [[ -z "${_PORT[$BLOCKLIST_ARR]:-}" ]]; then error "Unknown arr type: $BLOCKLIST_ARR — use lidarr, sonarr, or radarr" exit 1 fi _bl_port="${_PORT[$BLOCKLIST_ARR]}" _bl_ver="${_VER[$BLOCKLIST_ARR]}" _bl_ep="${_EP[$BLOCKLIST_ARR]}" _bl_id_field="${_ID[$BLOCKLIST_ARR]}" _bl_id_type="${_ID_TYPE[$BLOCKLIST_ARR]}" _bl_name_field="${_NAME[$BLOCKLIST_ARR]}" _bl_config_xml="${DOCKER_APPDATA_BASE}/${BLOCKLIST_ARR^}/config.xml" _bl_url="" _bl_key="" case "$BLOCKLIST_ARR" in lidarr) _bl_url="${LIDARR_URL:-}"; _bl_key="${LIDARR_API_KEY:-}" ;; sonarr) _bl_url="${SONARR_URL:-}"; _bl_key="${SONARR_API_KEY:-}" ;; radarr) _bl_url="${RADARR_URL:-}"; _bl_key="${RADARR_API_KEY:-}" ;; esac # Look up display name and internal id from local arr _bl_display_name="$BLOCKLIST_ID" _bl_internal_id="" if [[ -n "$_bl_url" ]] && [[ -n "$_bl_key" ]]; then _bl_lookup=$(_lookup_local_item "$_bl_url" "$_bl_key" "$_bl_ver" "$_bl_ep" \ "$_bl_id_field" "$_bl_id_type" "$_bl_name_field" "$BLOCKLIST_ID") if [[ -n "$_bl_lookup" ]]; then IFS=$'\t' read -r _bl_internal_id _bl_display_name <<< "$_bl_lookup" fi fi _blocklist_add "$BLOCKLIST_ARR" "$BLOCKLIST_ID" "$_bl_display_name" "${BLOCKLIST_REASON:-manually excluded}" echo " Blocklisted [$BLOCKLIST_ARR] $_bl_display_name ($BLOCKLIST_ID)" # Remove from local arr (deleteFiles=false — arr_cleanup handles file removal) if [[ -n "$_bl_internal_id" ]]; then _bl_http=$(_delete_local_item "$_bl_url" "$_bl_key" "$_bl_ver" "$_bl_ep" "$_bl_internal_id") if [[ "$_bl_http" == "200" ]]; then log "Removed from local ${BLOCKLIST_ARR^}: $_bl_display_name" else warn "Failed to remove from local ${BLOCKLIST_ARR^} (HTTP ${_bl_http:-no response}) — remove manually via UI" fi else log "Not found in local ${BLOCKLIST_ARR^} — already removed or not tracked locally" fi # Remove from all remote arrs for _bl_node_id in "${REMOTE_NODES[@]}"; do _bl_node_name="${!_bl_node_id}" _bl_result=$(_delete_remote_item "$_bl_node_id" "$_bl_port" "$_bl_ver" "$_bl_ep" \ "$_bl_config_xml" "$_bl_id_field" "$_bl_id_type" "$BLOCKLIST_ID") case "$_bl_result" in 200) log "Removed from $_bl_node_name ${BLOCKLIST_ARR^}: $_bl_display_name" ;; not_found) log "Not found on $_bl_node_name ${BLOCKLIST_ARR^} — already removed or not tracked" ;; *) warn "Failed to remove from $_bl_node_name ${BLOCKLIST_ARR^} (${_bl_result:-SSH error}) — remove manually via UI" ;; esac done echo "" echo " Files are now orphans on all nodes — arr_cleanup.sh will remove them on next run" exit 0 ;; remove) if [[ -z "$BLOCKLIST_ARR" ]] || [[ -z "$BLOCKLIST_ID" ]]; then error "Usage: arr_sync.sh --blocklist-remove " exit 1 fi _blocklist_remove "$BLOCKLIST_ARR" "$BLOCKLIST_ID" echo " Removed [$BLOCKLIST_ARR] $BLOCKLIST_ID from blocklist" exit 0 ;; esac fi # ============================================================================================== # ── STATUS ──────────────────────────────────────────────────────────────────────────────────── # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY ARR SYNC STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_HOST Remote nodes: ${REMOTE_NODES[*]}" echo "$ICON_GEAR Appdata base: $DOCKER_APPDATA_BASE" echo "$ICON_GEAR Blocklist: $ARR_SYNC_BLOCKLIST" echo "$ICON_GEAR Connect timeout: ${ARR_SYNC_CONNECT_TIMEOUT}s" echo "$ICON_GEAR API timeout: ${ARR_SYNC_API_TIMEOUT}s" echo "" echo " Arr Port URL" for _arr in "${ARR_TYPES[@]}"; do local _url="" _key="" case "$_arr" in lidarr) _url="${LIDARR_URL:-not configured}" ;; sonarr) _url="${SONARR_URL:-not configured}" ;; radarr) _url="${RADARR_URL:-not configured}" ;; esac printf ' %-8s %-6s %s\n' "$_arr" "${_PORT[$_arr]}" "$_url" done unset _arr _url echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ── REMOTE HELPERS ──────────────────────────────────────────────────────────────────────────── # ============================================================================================== _resolve_node_ip() { local node_id="$1" resolve_tailscale_ip "${!node_id}" } # Check if arr is reachable on remote node _remote_arr_up() { local node_ip="$1" port="$2" api_ver="$3" config_xml="$4" ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$node_ip" \ "KEY=\$(grep -oP '(?<=)[^<]+' '${config_xml}' 2>/dev/null) [[ -z \"\$KEY\" ]] && exit 1 curl -sf --max-time 5 -H \"X-Api-Key: \$KEY\" \ 'http://localhost:${port}/api/${api_ver}/system/status' >/dev/null" 2>/dev/null } # Fetch full library from remote arr — returns raw JSON array _remote_library() { local node_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5" ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$node_ip" \ "KEY=\$(grep -oP '(?<=)[^<]+' '${config_xml}' 2>/dev/null) [[ -z \"\$KEY\" ]] && exit 1 curl -sf --max-time ${ARR_SYNC_API_TIMEOUT} \ -H \"X-Api-Key: \$KEY\" \ 'http://localhost:${port}/api/${api_ver}/${endpoint}'" 2>/dev/null } # Fetch remote arr defaults: qualityProfileId, rootFolderPath, metadataProfileId (Lidarr) _remote_defaults() { local node_ip="$1" port="$2" api_ver="$3" arr_type="$4" config_xml="$5" local meta_field="" [[ "$arr_type" == "lidarr" ]] && \ meta_field=', metadataProfileId: ($mp | map(select(.name == "Standard")) | .[0].id // .[0].id // 1)' ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$node_ip" bash </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${config_xml}' 2>/dev/null) [[ -z "\$KEY" ]] && exit 1 QP=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/qualityprofile") RF=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/rootfolder") MP=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/metadataprofile" 2>/dev/null || echo '[]') jq -n --argjson qp "\$QP" --argjson rf "\$RF" --argjson mp "\$MP" \ '{qualityProfileId: (\$qp | .[0].id // 1), rootFolderPath: (\$rf | .[0].path // "")${meta_field}}' REMOTE } # Add item to remote arr — payload is base64-encoded to avoid SSH quoting issues _remote_add() { local node_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5" local encoded="$6" # base64-encoded JSON body ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \ root@"$node_ip" bash </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${config_xml}' 2>/dev/null) [[ -z "\$KEY" ]] && exit 1 BODY=\$(printf '%s' '${encoded}' | base64 -d) curl -sf -o /dev/null -w '%{http_code}' -X POST \ -H "X-Api-Key: \$KEY" \ -H 'Content-Type: application/json' \ -d "\$BODY" \ "http://localhost:${port}/api/${api_ver}/${endpoint}" REMOTE } # ============================================================================================== # ── LOCAL HELPERS ───────────────────────────────────────────────────────────────────────────── # ============================================================================================== _local_library() { local url="$1" api_key="$2" api_ver="$3" endpoint="$4" curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \ -H "X-Api-Key: $api_key" \ "${url}/api/${api_ver}/${endpoint}" 2>/dev/null } _local_defaults() { local url="$1" api_key="$2" api_ver="$3" arr_type="$4" local qp rf mp meta_field="" qp=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/qualityprofile" | jq '.[0].id // 1') rf=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/rootfolder" | jq -r '.[0].path // ""') if [[ "$arr_type" == "lidarr" ]]; then mp=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/metadataprofile" | \ jq 'map(select(.name == "Standard")) | .[0].id // .[0].id // 1') jq -n --argjson qp "$qp" --arg rf "$rf" --argjson mp "$mp" \ '{qualityProfileId: $qp, rootFolderPath: $rf, metadataProfileId: $mp}' else jq -n --argjson qp "$qp" --arg rf "$rf" \ '{qualityProfileId: $qp, rootFolderPath: $rf}' fi } _local_add() { local url="$1" api_key="$2" api_ver="$3" endpoint="$4" payload="$5" curl -sf -o /dev/null -w '%{http_code}' -X POST \ -H "X-Api-Key: $api_key" \ -H "Content-Type: application/json" \ -d "$payload" \ "${url}/api/${api_ver}/${endpoint}" 2>/dev/null } # ============================================================================================== # ── PAYLOAD BUILDER ─────────────────────────────────────────────────────────────────────────── # ============================================================================================== # Builds the minimal POST body to add an item to an arr. # Uses the TARGET node's own defaults — never copies source node settings. _build_payload() { local arr_type="$1" stable_id="$2" display_name="$3" monitored="$4" defaults_json="$5" case "$arr_type" in lidarr) jq -n \ --arg id "$stable_id" \ --arg nm "$display_name" \ --argjson mn "$monitored" \ --argjson df "$defaults_json" \ '{ foreignArtistId: $id, artistName: $nm, monitored: $mn, qualityProfileId: $df.qualityProfileId, metadataProfileId: ($df.metadataProfileId // 1), rootFolderPath: $df.rootFolderPath, addOptions: {monitor: "all", searchForMissingAlbums: false} }' ;; sonarr) jq -n \ --argjson id "$stable_id" \ --arg nm "$display_name" \ --argjson mn "$monitored" \ --argjson df "$defaults_json" \ '{ tvdbId: $id, title: $nm, monitored: $mn, qualityProfileId: $df.qualityProfileId, rootFolderPath: $df.rootFolderPath, seasons: [], addOptions: {searchForMissingEpisodes: false, monitor: "all"} }' ;; radarr) jq -n \ --argjson id "$stable_id" \ --arg nm "$display_name" \ --argjson mn "$monitored" \ --argjson df "$defaults_json" \ '{ tmdbId: $id, title: $nm, monitored: $mn, qualityProfileId: $df.qualityProfileId, rootFolderPath: $df.rootFolderPath, addOptions: {searchForMovie: false} }' ;; esac } # ============================================================================================== # ── CORE SYNC — one arr type across all remote nodes ────────────────────────────────────────── # ============================================================================================== _sync_arr() { local arr_type="$1" local port="${_PORT[$arr_type]}" local api_ver="${_VER[$arr_type]}" local endpoint="${_EP[$arr_type]}" local id_field="${_ID[$arr_type]}" local name_field="${_NAME[$arr_type]}" local id_type="${_ID_TYPE[$arr_type]}" local config_xml="${DOCKER_APPDATA_BASE}/$(echo "${arr_type^}")/config.xml" # Resolve local credentials local local_url local_key case "$arr_type" in lidarr) local_url="${LIDARR_URL:-}"; local_key="${LIDARR_API_KEY:-}" ;; sonarr) local_url="${SONARR_URL:-}"; local_key="${SONARR_API_KEY:-}" ;; radarr) local_url="${RADARR_URL:-}"; local_key="${RADARR_API_KEY:-}" ;; esac if [[ -z "$local_url" ]] || [[ -z "$local_key" ]]; then log "${arr_type^}: not configured on $MY_ID — skipping" return 0 fi echo "" echo "━━━ ${arr_type^} ━━━" # ── Fetch local library ──────────────────────────────────────────────────────────────────── local local_json local_json=$(_local_library "$local_url" "$local_key" "$api_ver" "$endpoint") if [[ -z "$local_json" ]] || ! echo "$local_json" | jq -e '.' >/dev/null 2>&1; then warn "${arr_type^}: could not fetch local library — skipping" return 0 fi # Build local ID map: stable_id → "display_name|monitored" declare -A local_ids local local_count=0 local _jq_id [[ "$id_type" == "string" ]] && _jq_id=".${id_field}" || _jq_id="(.${id_field} | tostring)" while IFS=$'\t' read -r stable_id display_name monitored; do [[ -z "$stable_id" ]] && continue local_ids["$stable_id"]="${display_name}|${monitored}" (( local_count++ )) done < <(echo "$local_json" | jq -r \ ".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv" 2>/dev/null) unset _jq_id log "${arr_type^}: $local_count items in local library" local total_added_local=0 total_added_remote=0 total_skipped=0 # ── Sync with each remote node ───────────────────────────────────────────────────────────── for node_id in "${REMOTE_NODES[@]}"; do local node_name="${!node_id}" log "${arr_type^}: syncing with $node_name..." local node_ip node_ip=$(_resolve_node_ip "$node_id") || { warn "${arr_type^}: cannot resolve Tailscale IP for $node_name — skipping" continue } if ! _remote_arr_up "$node_ip" "$port" "$api_ver" "$config_xml"; then log "${arr_type^}: not reachable on $node_name — skipping" continue fi local remote_json remote_json=$(_remote_library "$node_ip" "$port" "$api_ver" "$endpoint" "$config_xml") if [[ -z "$remote_json" ]] || ! echo "$remote_json" | jq -e '.' >/dev/null 2>&1; then warn "${arr_type^}: could not fetch library from $node_name — skipping" continue fi # Build remote ID map declare -A remote_ids local remote_count=0 local _jq_id [[ "$id_type" == "string" ]] && _jq_id=".${id_field}" || _jq_id="(.${id_field} | tostring)" while IFS=$'\t' read -r stable_id display_name monitored; do [[ -z "$stable_id" ]] && continue remote_ids["$stable_id"]="${display_name}|${monitored}" (( remote_count++ )) done < <(echo "$remote_json" | jq -r \ ".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv" 2>/dev/null) unset _jq_id log "${arr_type^}: $remote_count items on $node_name" # ── Remote → Local: items on remote not in local ─────────────────────────────────────── local to_add_local=() for stable_id in "${!remote_ids[@]}"; do [[ -n "${local_ids[$stable_id]:-}" ]] && continue if _is_blocklisted "$arr_type" "$stable_id"; then log "BLOCKLISTED [$arr_type] $stable_id — skipping" (( total_skipped++ )) continue fi to_add_local+=("$stable_id") done if [[ "${#to_add_local[@]}" -gt 0 ]]; then local local_defs local_defs=$(_local_defaults "$local_url" "$local_key" "$api_ver" "$arr_type") if [[ -z "$local_defs" ]]; then warn "${arr_type^}: could not fetch local defaults — skipping adds from $node_name" else for stable_id in "${to_add_local[@]}"; do IFS='|' read -r display_name monitored <<< "${remote_ids[$stable_id]}" local payload payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \ "$monitored" "$local_defs") if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: would add to local ${arr_type^}: $display_name ($stable_id)" (( total_added_local++ )) else local http_code http_code=$(_local_add "$local_url" "$local_key" "$api_ver" \ "$endpoint" "$payload") if [[ "$http_code" == "201" ]] || [[ "$http_code" == "200" ]]; then log "Added to local ${arr_type^}: $display_name" local_ids["$stable_id"]="${display_name}|${monitored}" (( total_added_local++ )) else warn "Failed to add to local ${arr_type^}: $display_name (HTTP $http_code)" fi fi done fi fi # ── Local → Remote: items on local not on remote ─────────────────────────────────────── local to_add_remote=() for stable_id in "${!local_ids[@]}"; do [[ -n "${remote_ids[$stable_id]:-}" ]] && continue if _is_blocklisted "$arr_type" "$stable_id"; then (( total_skipped++ )) continue fi to_add_remote+=("$stable_id") done if [[ "${#to_add_remote[@]}" -gt 0 ]]; then local remote_defs remote_defs=$(_remote_defaults "$node_ip" "$port" "$api_ver" "$arr_type" "$config_xml") if [[ -z "$remote_defs" ]]; then warn "${arr_type^}: could not fetch defaults from $node_name — skipping remote adds" else for stable_id in "${to_add_remote[@]}"; do IFS='|' read -r display_name monitored <<< "${local_ids[$stable_id]}" local payload payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \ "$monitored" "$remote_defs") if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: would add to $node_name ${arr_type^}: $display_name ($stable_id)" (( total_added_remote++ )) else local encoded http_code encoded=$(printf '%s' "$payload" | base64 -w0) http_code=$(_remote_add "$node_ip" "$port" "$api_ver" "$endpoint" \ "$config_xml" "$encoded") if [[ "$http_code" == "201" ]] || [[ "$http_code" == "200" ]]; then log "Added to $node_name ${arr_type^}: $display_name" (( total_added_remote++ )) else warn "Failed to add to $node_name ${arr_type^}: $display_name (HTTP $http_code)" fi fi done fi fi log " $node_name: +${#to_add_local[@]} local | +${#to_add_remote[@]} remote | $total_skipped blocklisted" unset remote_ids declare -A remote_ids done echo " Total added to local: $total_added_local | to remotes: $total_added_remote | blocklisted: $total_skipped" unset local_ids declare -A local_ids } # ============================================================================================== # ━━━ Main ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SYNC Loading Blocklist ━━━" _load_blocklist START=$(date +%s) for arr_type in "${ARR_TYPES[@]}"; do _sync_arr "$arr_type" done END=$(date +%s) echo "" echo "━━━━━ $ICON_SUMMARY ARR SYNC SUMMARY ━━━━━" echo "$ICON_HOST Node: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_SYNC Peers: ${REMOTE_NODES[*]}" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes were made" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0