diff --git a/Media/arr_sync.sh b/Media/arr_sync.sh index c6b139a..38a5019 100755 --- a/Media/arr_sync.sh +++ b/Media/arr_sync.sh @@ -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*__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 </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${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 '(?<=)[^<]+' '${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 '(?<=)[^<]+' '${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 </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${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 </dev/null KEY=\$(grep -oP '(?<=)[^<]+' '${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++ ))