arr_sync: cache-first remote API calls, SSH fallback

Remote functions (_remote_arr_up, _remote_library, _remote_defaults,
_remote_add, _delete_remote_item) now check ${node_id}_<ARR>_API_KEY
from the environment first. When found (conf cache populated by
conf_sync.sh + load_config.sh sourcing /tmp/.vv/), they call the arr
API directly over Tailscale — no SSH connection needed.

SSH fallback (grep config.xml + localhost API) remains for the first
boot before conf_sync has run or when the partner is offline.

config_xml is no longer passed between functions — computed internally
in the SSH path only. Call sites in _sync_arr and blocklist-add updated
accordingly.
This commit is contained in:
Gmer4Lfe
2026-06-04 22:20:38 -04:00
parent d70c682be2
commit 0ded0c87a1
+90 -25
View File
@@ -37,11 +37,13 @@
# 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.
# 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
@@ -311,11 +313,12 @@ _delete_local_item() {
"${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.
# 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" endpoint="$4" config_xml="$5"
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
@@ -328,6 +331,21 @@ _delete_remote_item() {
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)
@@ -381,7 +399,6 @@ if [[ -n "$BLOCKLIST_ACTION" ]]; then
_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:-}" ;;
@@ -418,8 +435,8 @@ if [[ -n "$BLOCKLIST_ACTION" ]]; then
# 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")
_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" ;;
@@ -481,8 +498,17 @@ _resolve_node_ip() {
}
# Check if arr is reachable on remote node
# Args: node_id node_ip port api_ver arr_type
_remote_arr_up() {
local node_ip="$1" port="$2" api_ver="$3" config_xml="$4"
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)
@@ -492,8 +518,17 @@ _remote_arr_up() {
}
# 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_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5"
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)
@@ -504,12 +539,31 @@ _remote_library() {
}
# Fetch remote arr defaults: qualityProfileId, rootFolderPath, metadataProfileId (Lidarr)
# Args: node_id node_ip port api_ver arr_type
_remote_defaults() {
local node_ip="$1" port="$2" api_ver="$3" arr_type="$4" config_xml="$5"
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)
@@ -522,11 +576,23 @@ jq -n --argjson qp "\$QP" --argjson rf "\$RF" --argjson mp "\$MP" \
REMOTE
}
# Add item to remote arr — payload is base64-encoded to avoid SSH quoting issues
# 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_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5"
local encoded="$6" # base64-encoded JSON body
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)
@@ -647,7 +713,6 @@ _sync_arr() {
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
@@ -701,13 +766,13 @@ _sync_arr() {
continue
}
if ! _remote_arr_up "$node_ip" "$port" "$api_ver" "$config_xml"; then
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_ip" "$port" "$api_ver" "$endpoint" "$config_xml")
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
@@ -783,7 +848,7 @@ _sync_arr() {
if [[ "${#to_add_remote[@]}" -gt 0 ]]; then
local remote_defs
remote_defs=$(_remote_defaults "$node_ip" "$port" "$api_ver" "$arr_type" "$config_xml")
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
@@ -798,8 +863,8 @@ _sync_arr() {
else
local encoded http_code
encoded=$(printf '%s' "$payload" | base64 -w0)
http_code=$(_remote_add "$node_ip" "$port" "$api_ver" "$endpoint" \
"$config_xml" "$encoded")
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++ ))