audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops
This commit is contained in:
+960
@@ -0,0 +1,960 @@
|
||||
#!/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 Access — Cache-First, SSH Fallback
|
||||
# If conf_sync.sh has populated /tmp/.vv/config/cached/.confs/ and
|
||||
# load_config.sh has sourced it, HOST*_<ARR>_API_KEY vars are available
|
||||
# in the environment. Remote functions use them to call the arr API
|
||||
# directly over Tailscale (no SSH, no remote shell). If the cached key
|
||||
# is absent (first boot, cache not yet populated) the functions fall back
|
||||
# to SSHing in and reading the key from config.xml on the remote node.
|
||||
#
|
||||
# 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 <mbid> "reason" — remove from all arrs + tombstone
|
||||
# arr_sync.sh --blocklist-add sonarr <tvdbId> "reason" — remove from all arrs + tombstone
|
||||
# arr_sync.sh --blocklist-add radarr <tmdbId> "reason" — remove from all arrs + tombstone
|
||||
# arr_sync.sh --blocklist-remove lidarr <id> — 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 <id> "reason"
|
||||
# --blocklist-remove lidarr <id>
|
||||
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}"
|
||||
|
||||
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
|
||||
|
||||
log "$ICON_GEAR Config: api-timeout=${ARR_SYNC_API_TIMEOUT}s connect-timeout=${ARR_SYNC_CONNECT_TIMEOUT}s blocklist=${ARR_SYNC_BLOCKLIST}"
|
||||
log "$ICON_GEAR Ports: lidarr=${ARR_SYNC_LIDARR_PORT} sonarr=${ARR_SYNC_SONARR_PORT} radarr=${ARR_SYNC_RADARR_PORT}"
|
||||
|
||||
# ── 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.
|
||||
# Outputs: HTTP code on success | "not_found" if item absent | empty on failure.
|
||||
# deleteFiles=false — files become orphans for arr_cleanup to handle with its safety checks.
|
||||
# Args: node_id port api_ver arr_type endpoint id_field id_type stable_id
|
||||
_delete_remote_item() {
|
||||
local node_id="$1" port="$2" api_ver="$3" arr_type="$4" endpoint="$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
|
||||
|
||||
local _kvar="${node_id}_${arr_type^^}_API_KEY"; local cached_key="${!_kvar:-}"
|
||||
if [[ -n "$cached_key" ]]; then
|
||||
local library internal_id
|
||||
library=$(curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \
|
||||
-H "X-Api-Key: $cached_key" \
|
||||
"http://${node_ip}:${port}/api/${api_ver}/${endpoint}" 2>/dev/null)
|
||||
[[ -z "$library" ]] && return 1
|
||||
internal_id=$(echo "$library" | jq -r "${select_expr}" 2>/dev/null | head -1)
|
||||
[[ -z "$internal_id" ]] && echo "not_found" && return 0
|
||||
curl -sf -o /dev/null -w '%{http_code}' -X DELETE \
|
||||
-H "X-Api-Key: $cached_key" \
|
||||
"http://${node_ip}:${port}/api/${api_ver}/${endpoint}/${internal_id}?deleteFiles=false" 2>/dev/null
|
||||
return
|
||||
fi
|
||||
local config_xml="${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml"
|
||||
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
|
||||
root@"$node_ip" bash <<REMOTE 2>/dev/null
|
||||
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${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 <arr_type> <stable_id> [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_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" \
|
||||
"$BLOCKLIST_ARR" "$_bl_ep" "$_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 <arr_type> <stable_id>"
|
||||
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
|
||||
# Args: node_id node_ip port api_ver arr_type
|
||||
_remote_arr_up() {
|
||||
local node_id="$1" node_ip="$2" port="$3" api_ver="$4" arr_type="$5"
|
||||
local _kvar="${node_id}_${arr_type^^}_API_KEY"; local cached_key="${!_kvar:-}"
|
||||
if [[ -n "$cached_key" ]]; then
|
||||
curl -sf --max-time 5 \
|
||||
-H "X-Api-Key: $cached_key" \
|
||||
"http://${node_ip}:${port}/api/${api_ver}/system/status" >/dev/null 2>/dev/null
|
||||
return
|
||||
fi
|
||||
local config_xml="${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml"
|
||||
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
|
||||
root@"$node_ip" \
|
||||
"KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${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
|
||||
# Args: node_id node_ip port api_ver arr_type endpoint
|
||||
_remote_library() {
|
||||
local node_id="$1" node_ip="$2" port="$3" api_ver="$4" arr_type="$5" endpoint="$6"
|
||||
local _kvar="${node_id}_${arr_type^^}_API_KEY"; local cached_key="${!_kvar:-}"
|
||||
if [[ -n "$cached_key" ]]; then
|
||||
curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \
|
||||
-H "X-Api-Key: $cached_key" \
|
||||
"http://${node_ip}:${port}/api/${api_ver}/${endpoint}" 2>/dev/null
|
||||
return
|
||||
fi
|
||||
local config_xml="${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml"
|
||||
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
|
||||
root@"$node_ip" \
|
||||
"KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${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)
|
||||
# Args: node_id node_ip port api_ver arr_type
|
||||
_remote_defaults() {
|
||||
local node_id="$1" node_ip="$2" port="$3" api_ver="$4" arr_type="$5"
|
||||
local _kvar="${node_id}_${arr_type^^}_API_KEY"; local cached_key="${!_kvar:-}"
|
||||
if [[ -n "$cached_key" ]]; then
|
||||
local base_url="http://${node_ip}:${port}/api/${api_ver}"
|
||||
local qp rf mp
|
||||
qp=$(curl -sf -H "X-Api-Key: $cached_key" "${base_url}/qualityprofile" 2>/dev/null | jq '.[0].id // 1')
|
||||
rf=$(curl -sf -H "X-Api-Key: $cached_key" "${base_url}/rootfolder" 2>/dev/null | jq -r '.[0].path // ""')
|
||||
[[ -z "$qp" ]] && return 1
|
||||
if [[ "$arr_type" == "lidarr" ]]; then
|
||||
mp=$(curl -sf -H "X-Api-Key: $cached_key" "${base_url}/metadataprofile" 2>/dev/null | \
|
||||
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
|
||||
return
|
||||
fi
|
||||
local config_xml="${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml"
|
||||
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 <<REMOTE 2>/dev/null
|
||||
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${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 quoting issues
|
||||
# Args: node_id node_ip port api_ver arr_type endpoint encoded_payload
|
||||
_remote_add() {
|
||||
local node_id="$1" node_ip="$2" port="$3" api_ver="$4" arr_type="$5" endpoint="$6"
|
||||
local encoded="$7"
|
||||
local _kvar="${node_id}_${arr_type^^}_API_KEY"; local cached_key="${!_kvar:-}"
|
||||
if [[ -n "$cached_key" ]]; then
|
||||
local body
|
||||
body=$(printf '%s' "$encoded" | base64 -d)
|
||||
curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "X-Api-Key: $cached_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"http://${node_ip}:${port}/api/${api_ver}/${endpoint}" 2>/dev/null
|
||||
return
|
||||
fi
|
||||
local config_xml="${DOCKER_APPDATA_BASE}/${arr_type^}/config.xml"
|
||||
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
|
||||
root@"$node_ip" bash <<REMOTE 2>/dev/null
|
||||
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${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
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── MONITORED ENFORCEMENT — re-monitor any unmonitored items via bulk editor ──────────────────
|
||||
# ==============================================================================================
|
||||
# All library members must be monitored. Unmonitored items won't be searched and eventually
|
||||
# lose their files when cleanup runs after the series is removed from the arr.
|
||||
|
||||
_enforce_monitored() {
|
||||
local arr_type="$1" url="$2" api_key="$3" api_ver="$4" local_json="$5"
|
||||
|
||||
local bulk_endpoint ids_key
|
||||
case "$arr_type" in
|
||||
sonarr) bulk_endpoint="series/editor"; ids_key="seriesIds" ;;
|
||||
radarr) bulk_endpoint="movie/editor"; ids_key="movieIds" ;;
|
||||
lidarr) bulk_endpoint="artist/editor"; ids_key="artistIds" ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local ids_json count
|
||||
ids_json=$(echo "$local_json" | jq '[.[] | select(.monitored == false) | .id]' 2>/dev/null)
|
||||
count=$(echo "$ids_json" | jq 'length' 2>/dev/null || echo 0)
|
||||
[[ "$count" -eq 0 ]] && return 0
|
||||
|
||||
warn "${arr_type^}: $count unmonitored items — re-monitoring"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
warn "DRY RUN: would re-monitor $count ${arr_type^} items"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local body http_code
|
||||
body=$(jq -n --arg k "$ids_key" --argjson ids "$ids_json" '{($k): $ids, monitored: true}')
|
||||
http_code=$(curl -sf -o /dev/null -w '%{http_code}' -X PUT \
|
||||
-H "X-Api-Key: $api_key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"${url}/api/${api_ver}/${bulk_endpoint}" 2>/dev/null)
|
||||
|
||||
if [[ "$http_code" == "200" || "$http_code" == "202" ]]; then
|
||||
log "${arr_type^}: re-monitored $count items ✅"
|
||||
else
|
||||
warn "${arr_type^}: bulk re-monitor failed (HTTP $http_code)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==============================================================================================
|
||||
# ── 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]}"
|
||||
|
||||
# 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"
|
||||
|
||||
# ── Enforce monitored — fix any unmonitored items before sync ─────────────────────────────
|
||||
_enforce_monitored "$arr_type" "$local_url" "$local_key" "$api_ver" "$local_json"
|
||||
|
||||
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_id" "$node_ip" "$port" "$api_ver" "$arr_type"; then
|
||||
log "${arr_type^}: not reachable on $node_name — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local remote_json
|
||||
remote_json=$(_remote_library "$node_id" "$node_ip" "$port" "$api_ver" "$arr_type" "$endpoint")
|
||||
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 _ <<< "${remote_ids[$stable_id]}"
|
||||
local payload
|
||||
payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \
|
||||
"true" "$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}|true"
|
||||
(( 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_id" "$node_ip" "$port" "$api_ver" "$arr_type")
|
||||
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 _ <<< "${local_ids[$stable_id]}"
|
||||
local payload
|
||||
payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \
|
||||
"true" "$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_id" "$node_ip" "$port" "$api_ver" \
|
||||
"$arr_type" "$endpoint" "$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
|
||||
Reference in New Issue
Block a user