Fix dead/incorrect vars and consolidate duplicated logic into common.sh

Codebase-wide audit pass: fixed real bugs (SSH hangs missing BatchMode,
local-outside-function no-ops, variable name collisions, a truncated
ratio calc, wrong state-dir path, DARK vs NO_INTERNET drift, and more),
then pulled logic that was duplicated across multiple scripts — arr
cleanup safety gates, docker restart ordering, container maintenance
stop/restart, watchdog state-file helpers, partnership role resolution,
cert expiry checks, remote node discovery, and TMDB discovery scoring —
into common.sh so each now has a single implementation.
This commit is contained in:
Gmer4Lfe
2026-07-03 23:52:33 -04:00
parent ef3980cf07
commit 6623d1e776
46 changed files with 921 additions and 1398 deletions
+653 -107
View File
@@ -49,8 +49,7 @@
# check_arr_version() added — API version safety gate before arr operations
# check_api() added — pre-flight API reachability check
# ping_remote(), ping_internet() — non-fatal ping for fallback use
# check_local_array(), check_remote_array() — array health checks
# check_remote_docker() — Docker daemon health check
# check_remote_array() — array health check
# get_unraid_temp_thresholds() — reads thresholds from dynamix.cfg
# check_local_disk_temps() — pre-rsync temp check with exit codes 0/1/2
# stop/start local containers added alongside existing remote variants
@@ -79,15 +78,12 @@
# Called by lidarr/sonarr/radarr_cleanup.sh when files are deleted
#
#
# Three new safety functions added:
# Two new safety functions added:
# check_os_version_parity() — refuses remote ops on version mismatch
# reads OS version via platform_get_os_version() / platform_os_version_probe_cmd()
# major mismatch → abort | minor mismatch → configurable warn/abort
# check_remote_docker_daemon() — verifies remote Docker daemon before
# issuing any remote container commands — strike system → skip/retry/exit
# validate_unraid_cmd() — verifies unRAID-specific commands exist and
# produce expected output before use — notifies and exits calling script
# if command changed or disappeared after upgrade — other scripts unaffected
#
# ── VERSION ───────────────────────────────────────────────────────────────────────────────────
# Current: v3.5
@@ -130,6 +126,7 @@ ICON_SYNC="🔄" # transfer section header
ICON_RUN="🚀" # sync starting / rsync attempt
ICON_RETRY="🔁" # retry attempt
ICON_DONE="🏁" # transfer complete
ICON_GIT="🌱" # git pull/push section header
# Summary
ICON_SUMMARY="📋" # summary section header
@@ -269,6 +266,33 @@ format_duration() {
else echo "${rem}s"; fi
}
# Tiered human-readable size — picks B/MB/GB based on magnitude.
# Usage: format_bytes 5368709120 → 5.0GB
format_bytes() {
local bytes=${1:-0}
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# Fixed-precision bytes → GB (no tiering) — for values always compared against a GB threshold.
# Usage: bytes_to_gb 5368709120 [decimals=2] → 5.00
bytes_to_gb() {
local bytes=${1:-0} decimals=${2:-2}
awk "BEGIN {printf \"%.${decimals}f\", $bytes / 1073741824}"
}
# Fixed-precision KB → GB (no tiering) — df --output=used/avail reports in KB.
# Usage: kb_to_gb 5242880 [decimals=2] → 5.00
kb_to_gb() {
local kb=${1:-0} decimals=${2:-2}
awk "BEGIN {printf \"%.${decimals}f\", $kb / 1048576}"
}
# ==============================================================================================
# ── ARG PARSER ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -355,6 +379,17 @@ validate_int() {
log "$name validated: $value"
}
# Membership check against a list of values (e.g. an ignore list).
# Usage: is_in_list "$needle" "${HAYSTACK_ARRAY[@]}"
is_in_list() {
local needle="$1"; shift
local item
for item in "$@"; do
[[ "$needle" == "$item" ]] && return 0
done
return 1
}
# ==============================================================================================
# ── HOST DETECTION ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -506,6 +541,7 @@ detect_hosts() {
SYS_WATCHDOG_CHECK_MDSTAT SYS_WATCHDOG_CHECK_NETWORK
SYS_WATCHDOG_CHECK_SSHD SYS_WATCHDOG_CHECK_RUNAWAY
)
local _wd_var
for _wd_var in "${_wd_checks[@]}"; do
local _wd_src="${MY_ID}_${_wd_var}"
# Only alias if the host-specific var is set — preserves master.conf defaults
@@ -625,6 +661,86 @@ detect_hosts() {
log "$ICON_HOST Host: $LOCAL_SERVER_NAME$REMOTE_SERVER_NAME"
}
# Derives owner/mirror roles from PARTNERSHIP_OWNER_HOST — used by every Partnership/*.sh
# script. Requires detect_hosts() to have already run (needs MY_ID, SSH_KEY).
# Sets: OWNER_ID MIRROR_ID OWNER MIRROR MIRROR_SSH_KEY OWNER_SSH_KEY AM_OWNER AM_MIRROR
# Does NOT set any *_STATE_FILE vars — callers that need those set them after calling this,
# since not every Partnership script needs the same set (transfer/onboard need fewer than
# manager/offboard).
partnership_resolve_roles() {
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
# SSH_KEY (set by detect_hosts) is this server's own private key. The remote accepts it
# because this server's PUBLIC key was installed there via ssh_setup.sh. With sparse
# checkout, each server only has its own host{N}.conf — the other server's key path is
# never available here. Use SSH_KEY for all outbound SSH regardless of mode.
MIRROR_SSH_KEY="$SSH_KEY"
OWNER_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
}
# Read a scalar/array var from a partner's own config via SSH — the partner sources its
# own load_config.sh + detect_hosts() remotely so the value reflects ITS host*.conf, not
# ours. Requires $SCRIPT_DIR to be set by the caller (one level above the repo root, same
# convention every Partnership/*.sh script already uses for its own SCRIPT_DIR).
# Usage: read_remote_conf_var "$mirror_ip" "VAR_NAME"
# read_remote_conf_array "$mirror_ip" "ARRAY_NAME" # one element per line
read_remote_conf_var() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s' \"\${${var_name}:-}\"" 2>/dev/null
}
read_remote_conf_array() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${var_name}[@]:-}\"" 2>/dev/null
}
# Probes a remote host's SCRIPTS_DIR (in case it differs from ours — e.g. one host runs
# internal storage mode, the other flash), falling back to our own $SCRIPTS_DIR if the
# probe fails or the remote isn't reachable. strict_host_key defaults to "yes" (matches
# existing behavior everywhere except partnership_transfer.sh, which passes "no" since
# it may be contacting a mirror for the first time during an ownership transfer).
# Usage: resolve_remote_scripts_dir "$ip" ["$ssh_key"] ["no"|"yes"]
resolve_remote_scripts_dir() {
local ip="$1" ssh_key="${2:-$SSH_KEY}" strict_host_key="${3:-yes}"
local probe_cmd result
local -a opts=(-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes)
[[ "$strict_host_key" == "no" ]] && opts+=(-o StrictHostKeyChecking=no)
probe_cmd=$(platform_scripts_dir_probe_cmd)
result=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" "${opts[@]}" root@"$ip" "$probe_cmd" \
2>/dev/null | tr -d '[:space:]')
echo "${result:-$SCRIPTS_DIR}"
}
# ==============================================================================================
# ── MULTI-NODE DISCOVERY ──────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Scans HOST1..HOST8 for every configured node other than this one. Requires detect_hosts()
# to have already run (needs MY_ID). Sets the global REMOTE_NODES array — empty if none found.
discover_remote_nodes() {
REMOTE_NODES=()
local _hv
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$_hv" == "$MY_ID" ]] && continue
[[ -z "${!_hv:-}" ]] && continue
REMOTE_NODES+=("$_hv")
done
}
# ==============================================================================================
# ── REMOTE IP RESOLUTION ──────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -669,6 +785,34 @@ resolve_tailscale_ip() {
echo "$ip"
}
# Strips the "unraid-" prefix (case-insensitive) and title-cases what remains.
# Usage: derive_short_name "unRAID-Gmer4Lfe" → "Gmer4lfe"
derive_short_name() {
local hostname="$1"
local short="${hostname,,}"
[[ "$short" == unraid-* ]] && short="${short:7}"
echo "${short^}"
}
# True if the given host*.conf basename belongs to this host (case-insensitive).
# Usage: is_own_conf_file "$(basename "$conf")"
is_own_conf_file() {
[[ "${1,,}" == "${MY_ID,,}.conf" ]]
}
# Sets key=value in a flat state file (setup.db style) — updates in place if the
# key exists, appends if not. No indentation handling — for master.conf use
# partnership_manager.sh's update_master_conf() instead.
# Usage: set_state_var "$state_file" "$key" "$value"
set_state_var() {
local file="$1" key="$2" value="$3"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$file"
else
echo "${key}=${value}" >> "$file"
fi
}
# ==============================================================================================
# ── CONNECTIVITY CHECKS ───────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -755,26 +899,6 @@ is_vm_manager_enabled() {
# ── LOCAL HEALTH CHECKS ───────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Verifies local storage is mounted and has shares.
# Non-fatal — returns status for caller to decide.
# Used by fallback before starting remote containers locally.
check_local_array() {
log "Checking local array..."
if ! platform_storage_healthy; then
error "$ICON_DISK Local array is not started — storage not mounted"
return 1
fi
local _storage_path file_count
_storage_path=$(platform_storage_path)
file_count=$(ls "$_storage_path" 2>/dev/null | wc -l)
if [[ "$file_count" -eq 0 ]]; then
error "$ICON_DISK Local array appears empty — shares may not be available"
return 1
fi
log "Local array is healthy"
return 0
}
# ==============================================================================================
# ── REMOTE HEALTH CHECKS ─────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -796,19 +920,6 @@ check_remote_array() {
return 0
}
# Verifies remote Docker daemon is responding.
# Non-fatal — returns status. A hung daemon means container commands silently fail.
check_remote_docker() {
log "Checking remote Docker daemon on $REMOTE_SERVER_NAME..."
if ! ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \
"timeout 10 docker ps" >/dev/null 2>&1; then
error "$ICON_CONTAINERS Remote Docker daemon not responding on $REMOTE_SERVER_NAME"
return 1
fi
log "Remote Docker daemon is healthy"
return 0
}
# Aborts if remote rootfs (/) usage is at or above ROOTFS_WARN threshold.
# Fatal — exits the calling script.
# When remote array is down rsync writes land on rootfs and fill it rapidly.
@@ -861,9 +972,14 @@ check_remote_share() {
# Returns 0 (true) if device is non-rotational (SSD/NVMe), 1 (false) if HDD.
# Checks /sys/block/<device>/queue/rotational — 0=SSD, 1=HDD.
is_ssd() {
local base
base=$(basename "$1")
[[ "$(cat "/sys/block/$base/queue/rotational" 2>/dev/null)" == "0" ]]
local drive="$1"
local base rotational
base=$(basename "$drive")
rotational="/sys/block/$base/queue/rotational"
[[ -f "$rotational" ]] && [[ "$(cat "$rotational" 2>/dev/null)" == "0" ]] && return 0
# NVMe is always SSD — some NVMe controllers don't expose rotational correctly
[[ "$drive" == *nvme* ]] && return 0
return 1
}
# Sets globals: UNRAID_DISK_HOT UNRAID_DISK_MAX UNRAID_SSD_HOT UNRAID_SSD_MAX
@@ -878,6 +994,37 @@ get_unraid_temp_thresholds() {
}
# Checks local disk temperatures before rsync.
# Fetches a domain's TLS cert expiry via openssl s_client + x509 and computes days
# remaining. Pure data — no logging, no thresholds — callers classify warn/crit
# themselves against their own CERT_WARN_DAYS/CERT_CRIT_DAYS and log however they like.
#
# Sets _CERT_DAYS, _CERT_EXPIRY (YYYY-MM-DD) on success. Sets _CERT_EXPIRY_RAW to the
# unparsed openssl date string only when parsing failed (empty when unreachable) so a
# caller that wants a more specific error message can distinguish the two failure modes.
#
# Usage: check_cert_expiry "$domain" [port=443] [timeout=$CERT_TIMEOUT or 10]
# Returns: 0 = fetched and parsed | 1 = unreachable or unparseable
check_cert_expiry() {
local domain="$1" port="${2:-443}" cert_timeout="${3:-${CERT_TIMEOUT:-10}}"
_CERT_DAYS=""
_CERT_EXPIRY=""
_CERT_EXPIRY_RAW=""
local expiry_str
expiry_str=$(echo | timeout "$cert_timeout" openssl s_client \
-connect "${domain}:${port}" -servername "$domain" \
2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
[[ -z "$expiry_str" ]] && return 1
local expiry_epoch
expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null)
if [[ -z "$expiry_epoch" ]]; then
_CERT_EXPIRY_RAW="$expiry_str"
return 1
fi
_CERT_DAYS=$(( (expiry_epoch - $(date +%s)) / 86400 ))
_CERT_EXPIRY=$(date -d "$expiry_str" '+%Y-%m-%d' 2>/dev/null)
return 0
}
# Reads temps and rotational flag from /var/local/emhttp/disks.ini.
# Uses unRAID's own thresholds from dynamix.cfg via get_unraid_temp_thresholds().
#
@@ -1305,6 +1452,62 @@ start_local_containers() {
done
}
# Builds a dependency-safe restart order — dependency containers (anything named as a
# value in WATCHDOG_DEPENDENCIES) restart first, then everything else in original order.
# Sets ORDERED_RESTART. Usage: build_restart_order CONTAINERS_ARRAY_NAME (bare name, e.g.
# build_restart_order DAILY_RESTART_CONTAINERS — not "${DAILY_RESTART_CONTAINERS[@]}").
build_restart_order() {
local -n _bro_containers="$1"
ORDERED_RESTART=()
local remaining=("${_bro_containers[@]}")
local placed=()
# First pass — add dependency containers that appear in our list
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local is_dependency=false
for dependent in "${!WATCHDOG_DEPENDENCIES[@]}"; do
if [[ "${WATCHDOG_DEPENDENCIES[$dependent]}" == *"$container"* ]]; then
is_dependency=true
break
fi
done
if [[ "$is_dependency" == true ]]; then
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
fi
done
# Second pass — add remaining containers (dependents and independents)
for container in "${remaining[@]}"; do
[[ -z "$container" ]] && continue
local already=false
for p in "${placed[@]}"; do [[ "$p" == "$container" ]] && already=true && break; done
if [[ "$already" == false ]]; then
ORDERED_RESTART+=("$container")
placed+=("$container")
fi
done
log "Restart order: ${ORDERED_RESTART[*]}"
}
# Waits CONTAINER_DELAY if this container depends on the last restarted one.
# Usage: check_dependency_delay "$container" "$last_restarted"
check_dependency_delay() {
local container="$1" last="$2"
[[ -z "$last" ]] && return
local deps="${WATCHDOG_DEPENDENCIES[$container]:-}"
if [[ -n "$deps" ]] && [[ "$deps" == *"$last"* ]]; then
log "Waiting ${CONTAINER_DELAY}s — $container depends on $last..."
sleep "$CONTAINER_DELAY"
fi
}
# ==============================================================================================
# ── RSYNC OPTIONS ─────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -1380,12 +1583,6 @@ _lock_file() {
echo "$LOCK_DIR/${1:-$(_lock_name)}.lock"
}
# Internal — release lock on exit (registered via EXIT trap)
_release_on_exit() {
local lockfile="$1"
[[ -f "$lockfile" ]] && rm -f "$lockfile"
}
# Acquire exclusive lock for this script.
# Usage: acquire_lock [strict|wait|continuous]
acquire_lock() {
@@ -1524,17 +1721,30 @@ acquire_rsync_lock() {
log "$ICON_LOCK rsync lock acquired: profile '$profile' (PID $$, active: $(( current_count + 1 ))/$RSYNC_MAX_CONCURRENT)"
}
# Internal — release rsync lock and decrement global counter on exit
_release_rsync_on_exit() {
local profile_lock="$1"
[[ -f "$profile_lock" ]] && rm -f "$profile_lock"
if [[ -f "$RSYNC_COUNT_FILE" ]]; then
local count
count=$(cat "$RSYNC_COUNT_FILE" 2>/dev/null || echo 1)
count=$(( count - 1 ))
[[ "$count" -lt 0 ]] && count=0
echo "$count" > "$RSYNC_COUNT_FILE"
fi
# ==============================================================================================
# ── FLAT STATE-FILE KEY/VALUE HELPERS ─────────────────────────────────────────────────────────
# ==============================================================================================
# Generic get/set for flat "key<sep>value" state files (one entry per line) — the pattern
# every watchdog's strike-tracking and state-file logic was independently reimplementing.
# grep -v + tempfile-swap on write, not sed -i in place — avoids sed treating a key containing
# regex metacharacters (container names, etc.) as part of the substitution pattern.
#
# Usage: wd_state_get "$key" "$file" [sep=:]
# wd_state_set "$key" "$value" "$file" [sep=:]
#
# Callers with a fixed state file and/or separator (e.g. stability_watchdog.sh's
# get_strikes/get_state_val, resource_watchdog.sh's rm_state_get/rm_state_get_eq) should
# keep their own thin same-named wrapper around these rather than changing call sites.
wd_state_get() {
local key="$1" file="$2" sep="${3:-:}"
grep -E "^${key}${sep}" "$file" 2>/dev/null | cut -d"$sep" -f2-
}
wd_state_set() {
local key="$1" value="$2" file="$3" sep="${4:-:}"
grep -vE "^${key}${sep}" "$file" 2>/dev/null > "${file}.tmp"
echo "${key}${sep}${value}" >> "${file}.tmp"
mv "${file}.tmp" "$file"
}
# ==============================================================================================
@@ -1632,6 +1842,348 @@ translate_path() {
fi
}
# ==============================================================================================
# ── CONTAINER STOP/RESTART FOR MAINTENANCE ────────────────────────────────────────────────────
# ==============================================================================================
# The "stop a container for a maintenance window, guarantee it comes back" shape shared by
# container_data_export.sh and emby_database_repair.sh. Callers still register their own EXIT
# trap (they may need extra cleanup, e.g. removing a partial archive) — the trap should call
# container_force_restart_if_needed() for the actual "still down? bring it back" part.
# Usage: container_stop_for_maintenance "$container" was_running_var_name \
# ["pre-stop reason message"] [log|warn] [post_stop_sleep=0]
# The reason message (if given) prints via the chosen level (default: log) right before the
# stop attempt, only when the container was actually running — callers use this for their
# own context ("stopping for clean export" vs "active sessions will be interrupted").
# Sets the named var true/false. Returns 1 (caller should exit) if the container doesn't
# exist or fails to stop; 0 otherwise (including the "wasn't running" case — caller logs
# its own context-specific message for that, since wording/visibility differs per caller).
container_stop_for_maintenance() {
local container="$1"
local -n _csm_was_running="$2"
local pre_stop_msg="${3:-}" pre_stop_level="${4:-log}" post_stop_sleep="${5:-0}"
_csm_was_running=false
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
case "$status" in
true)
_csm_was_running=true
if [[ -n "$pre_stop_msg" ]]; then
if [[ "$pre_stop_level" == "warn" ]]; then warn "$pre_stop_msg"; else log "$pre_stop_msg"; fi
fi
if [[ "$DRY_RUN" == false ]]; then
if timeout "$DOCKER_TIMEOUT" docker stop "$container" >/dev/null 2>&1; then
echo "$container stopped ✅"
[[ "$post_stop_sleep" -gt 0 ]] && sleep "$post_stop_sleep"
else
error "Failed to stop $container — aborting"
return 1
fi
else
warn "DRY RUN — would stop $container"
fi
;;
false) : ;; # not running — caller logs its own context-specific message
"")
error "$container not found — check container name"
return 1
;;
*)
warn "$container status: $status — proceeding with caution"
;;
esac
return 0
}
# Force-restart from inside a script's own EXIT trap if the container is still down after
# a crash mid-maintenance. No-op if it wasn't running before, in dry-run, or already up.
# Usage: container_force_restart_if_needed "$container" "$was_running"
container_force_restart_if_needed() {
local container="$1" was_running="$2"
[[ "$was_running" == true && "$DRY_RUN" == false ]] || return 0
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
if [[ "$status" != "true" ]]; then
warn "Restarting $container (cleanup)..."
timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1 || \
error "Failed to restart $container — start it manually"
fi
}
# Normal-path restart after maintenance completes — verifies it stayed up, notifies on failure.
# Sets RESTART_OK true/false.
# Usage: container_restart_after_maintenance "$container" "$was_running" [settle_sleep=3] [notify_label]
container_restart_after_maintenance() {
local container="$1" was_running="$2" settle_sleep="${3:-3}" notify_label="${4:-Container Maintenance}"
RESTART_OK=false
if [[ "$was_running" != true ]]; then
echo "$container was not running — leaving stopped (state respected) ✅"
RESTART_OK=true
return 0
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $container"
RESTART_OK=true
return 0
fi
log "Restarting $container..."
if timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1; then
sleep "$settle_sleep"
local post_status
post_status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
if [[ "$post_status" == "true" ]]; then
echo "$container restarted and running ✅"
RESTART_OK=true
else
error "$container started but crashed — check container logs"
notify "$container failed to stay running after maintenance on $(hostname)" \
"$notify_label" "warning"
fi
else
error "Failed to restart $container — start it manually"
notify "$container failed to restart after maintenance on $(hostname)" \
"$notify_label" "warning"
fi
}
# ==============================================================================================
# ── ARR CLEANUP SAFETY GATES ──────────────────────────────────────────────────────────────────
# ==============================================================================================
# Shared by lidarr/radarr/sonarr_cleanup.sh — these scripts DELETE files, so every function
# here is a direct byte-for-byte port of what was independently duplicated three times, not
# a rewrite. Callers should behave identically to before this consolidation.
# Safety Layer 1 — container running, not starting/unhealthy. Exits 1 (with notify) on failure.
# Usage: check_container_health "$LIDARR_CONTAINER" "$ARR_DOCKER_TIMEOUT" "Lidarr Cleanup"
check_container_health() {
local container="$1" docker_timeout="$2" notify_label="$3"
local running
running=$(timeout "$docker_timeout" docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null)
if [[ "$running" != "true" ]]; then
error "$container is not running — aborting"
notify "$notify_label aborted on $(hostname) — container not running" "$notify_label" "warning"
exit 1
fi
local health
health=$(timeout "$docker_timeout" docker inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null)
case "$health" in
healthy) info "$container is healthy" ;;
"") info "$container has no health check — proceeding" ;;
starting)
error "$container is still starting — aborting"
notify "$notify_label aborted on $(hostname) — container still starting" "$notify_label" "warning"
exit 1 ;;
unhealthy)
error "$container is unhealthy — aborting"
notify "$notify_label aborted on $(hostname) — container unhealthy" "$notify_label" "warning"
exit 1 ;;
*) warn "$container health: $health — proceeding with caution" ;;
esac
info "Safety layer 1 passed — container healthy"
}
# Safety Layer 6 — abort if tracked count dropped below min_pct of the last known count
# (protects against a misconfigured path / partial API response silently wiping the library).
# Writes the new baseline only if the check passes. Exits 1 (with notify) on failure.
# Usage: check_tracked_count_floor "$TRACKED_COUNT" "$LIDARR_TRACKED_COUNT_FILE" "$LIDARR_MIN_TRACKED_PCT" "Lidarr Cleanup"
check_tracked_count_floor() {
local tracked_count="$1" baseline_file="$2" min_pct="$3" notify_label="$4"
if [[ -f "$baseline_file" ]]; then
local last_count pct
last_count=$(cat "$baseline_file" 2>/dev/null || echo 0)
if [[ "$last_count" -gt 0 ]]; then
pct=$(awk "BEGIN {printf \"%d\", ($tracked_count / $last_count) * 100}")
if [[ "$pct" -lt "$min_pct" ]]; then
error "Tracked count dropped to ${pct}% of last run ($tracked_count vs $last_count)"
error "Suggests API issue — aborting to prevent mass deletion"
error "If expected (large library removal) delete: $baseline_file"
notify "$notify_label aborted on $(hostname) — tracked count dropped to ${pct}%" \
"$notify_label" "warning"
exit 1
fi
info "Tracked count: ${pct}% of last run ($tracked_count vs $last_count) ✅"
fi
else
info "No previous count on record — first run, saving baseline"
fi
echo "$tracked_count" > "$baseline_file"
}
# Filters --i-know-what-im-doing / --skip-age-check out of "$@" before parse_args sees them
# (both are cleanup-script-specific, not part of the shared arg parser).
# Sets I_KNOW, SKIP_AGE_CHECK, FILTERED_ARGS — call parse_args "${FILTERED_ARGS[@]}" after.
parse_destructive_flags() {
I_KNOW=false
SKIP_AGE_CHECK=false
FILTERED_ARGS=()
local arg
for arg in "$@"; do
case "$arg" in
--i-know-what-im-doing) I_KNOW=true ;;
--skip-age-check) SKIP_AGE_CHECK=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
}
# Prints the nuclear-mode banner and sleeps 10s (Ctrl+C window) when both destructive flags
# are set and this isn't a dry run. Call after parse_args. No-op otherwise.
nuclear_mode_warning() {
if [[ "$I_KNOW" == true ]] && [[ "$SKIP_AGE_CHECK" == true ]] && [[ "$DRY_RUN" != true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING — NUCLEAR MODE ACTIVE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Flags: --i-know-what-im-doing --skip-age-check"
echo " Age check: BYPASSED — deletes on first pass"
echo " Size threshold: BYPASSED — no GB limit"
echo " Data recovery: NOT POSSIBLE after deletion"
echo ""
echo " Review --dry-run output before proceeding."
echo " You have 10 seconds to cancel (Ctrl+C)..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
sleep 10
echo " Proceeding..."
echo ""
fi
}
# Safety Layer 7 — abort if total deletion size exceeds max_gb and --i-know-what-im-doing
# wasn't passed. Sets TOTAL_HUMAN. Exits 1 (with notify) on failure; warns and continues
# if I_KNOW is true (set by parse_destructive_flags).
# Usage: check_delete_size_threshold "$TOTAL_DELETE_BYTES" "$LIDARR_MAX_DELETE_GB" "Lidarr Cleanup"
check_delete_size_threshold() {
local total_bytes="$1" max_gb="$2" notify_label="$3"
local max_bytes
max_bytes=$(awk "BEGIN {printf \"%d\", $max_gb * 1073741824}")
if [[ "$total_bytes" -gt "$max_bytes" ]]; then
TOTAL_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $total_bytes / 1073741824}")
if [[ "$I_KNOW" != true ]]; then
echo ""
error "Deletion would exceed ${max_gb}GB — $TOTAL_HUMAN would be deleted"
error "Review ORPHAN lines above carefully before proceeding"
error "Rerun with: --i-know-what-im-doing"
error "To also bypass age check: add --skip-age-check"
notify "$notify_label halted on $(hostname)${TOTAL_HUMAN} requires --i-know-what-im-doing" \
"$notify_label" "warning"
exit 1
else
warn "OVERRIDE — deletion is $TOTAL_HUMAN — proceeding with --i-know-what-im-doing"
fi
fi
}
# True if filepath's extension (case-insensitive) matches one of the given extensions.
# Usage: has_extension "$filepath" "${LIDARR_EXTENSIONS[@]}"
has_extension() {
local filepath="$1"; shift
local ext="${filepath##*.}"
ext="${ext,,}"
local valid_ext
for valid_ext in "$@"; do
[[ "$ext" == "$valid_ext" ]] && return 0
done
return 1
}
# True if filepath's basename matches one of the given glob patterns.
# Usage: matches_pattern_list "$filepath" "${LIDARR_PROTECTED_PATTERNS[@]}"
matches_pattern_list() {
local filepath="$1"; shift
local filename
filename=$(basename "$filepath")
local pattern
for pattern in "$@"; do
# shellcheck disable=SC2254
case "$filename" in
$pattern) return 0 ;;
esac
done
return 1
}
# Builds the global ARR_PATH_MAP associative array (container path → host path) from
# ${MY_ID}_<ARR_TYPE>_PATH_MAP, for translate_path() to use. Usage: build_arr_path_map "LIDARR"
build_arr_path_map() {
local arr_type="$1"
declare -gA ARR_PATH_MAP=()
local _bapm_var="${MY_ID}_${arr_type}_PATH_MAP"
eval "for key in \"\${!${_bapm_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${_bapm_var}[\$key]}\"
done"
}
# Generic arr API GET wrapper with HTTP status check.
# Usage: arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr"
arr_api() {
local base_url="$1" api_key="$2" api_version="$3" endpoint="$4" label="${5:-Arr}"
local response http_code body
response=$(curl -sf \
--max-time 30 \
-H "X-Api-Key: $api_key" \
-w "\n%{http_code}" \
"${base_url}/api/${api_version}/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "$label API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
# Triggers an arr "command" endpoint with the given JSON payload, then polls every 10s until
# completed/failed/timeout, logging progress every 60s. Best-effort pre-flight — never treats
# an unreachable/timed-out scan as fatal, matching original per-script behavior.
# Usage: trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" \
# '{"name": "DownloadedAlbumsScan"}' "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}"
trigger_and_await_command() {
local base_url="$1" api_key="$2" api_version="$3" payload="$4" poll_timeout="${5:-600}"
local scan_response scan_cmd_id
scan_response=$(curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $api_key" \
-H "Content-Type: application/json" \
-d "$payload" \
"${base_url}/api/${api_version}/command" 2>/dev/null)
scan_cmd_id=$(echo "$scan_response" | jq -r '.id // empty' 2>/dev/null)
if [[ -z "$scan_cmd_id" ]]; then
warn "Could not trigger import scan — proceeding without pre-flight"
return 0
fi
info "Import scan queued (command ID: $scan_cmd_id) — waiting for completion..."
local polled=0 scan_status
while [[ "$polled" -lt "$poll_timeout" ]]; do
scan_status=$(curl -sf --max-time 10 \
-H "X-Api-Key: $api_key" \
"${base_url}/api/${api_version}/command/${scan_cmd_id}" 2>/dev/null | \
jq -r '.status // empty' 2>/dev/null)
case "$scan_status" in
completed) info "Import scan complete ✅"; return 0 ;;
failed) warn "Import scan reported failed — proceeding anyway"; return 0 ;;
esac
sleep 10
(( polled += 10 ))
[[ $(( polled % 60 )) -eq 0 ]] && log " Still scanning... (${polled}s elapsed)"
done
warn "Import scan timed out after ${poll_timeout}s — proceeding anyway"
return 0
}
# ==============================================================================================
# ── ARR VERSION CHECK ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -1692,6 +2244,48 @@ check_arr_version() {
fi
}
# ==============================================================================================
# ── TMDB DISCOVERY SCORING ────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Shared by playback_aware_radarr_discovery.sh and playback_aware_sonarr_discovery.sh — stage 2
# candidate scoring against TMDB rating/vote/seed-breadth data. Byte-for-byte identical between
# the two before this consolidation.
# Formats a TMDB vote_average×10 integer back to one decimal place (e.g. 78 → 7.8).
_fmt_rating() { local v="${1:-0}"; echo "${v::-1}.${v: -1}" 2>/dev/null || echo "$v"; }
# Stage 2: TMDB vote_average × 10 as integer (0-40)
_rating_score_s2() {
local v="$1"
if (( v >= 80 )); then echo 40
elif (( v >= 75 )); then echo 32
elif (( v >= 70 )); then echo 25
elif (( v >= 65 )); then echo 18
elif (( v >= 60 )); then echo 12
else echo 5
fi
}
# Stage 2: vote count (0-20)
_votes_score() {
local c="$1"
if (( c >= 10000 )); then echo 20
elif (( c >= 5000 )); then echo 15
elif (( c >= 1000 )); then echo 10
elif (( c >= 200 )); then echo 5
else echo 2
fi
}
# Stage 2: seed breadth (0-40)
_breadth_score() {
local seeds="$1"
if (( seeds >= 3 )); then echo 40
elif (( seeds == 2 )); then echo 25
else echo 10
fi
}
# ==============================================================================================
# ── EMBY LIBRARY SCAN ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -1911,54 +2505,6 @@ check_remote_docker_daemon() {
return 1
}
# ==============================================================================================
# ── UNRAID COMMAND VALIDATION ─────────────────────────────────────────────────────────────────
# ==============================================================================================
# Verifies a unRAID-specific command exists and produces expected output before use.
# Protects against commands moving, changing format, or disappearing after upgrades.
#
# If validation fails — notifies and returns 1. Caller exits its own section only.
# Other scripts that pass validation are unaffected.
#
# Usage:
# validate_unraid_cmd # "/usr/local/emhttp/plugins/dynamix/scripts/notify" # "--help" # "Usage" # "unRAID notify script"
#
# Arguments:
# $1 = full path to command
# $2 = test argument to pass (use "" for no argument)
# $3 = expected string in output (use "" to skip output check)
# $4 = human readable name for error messages
validate_unraid_cmd() {
local cmd_path="$1"
local test_arg="$2"
local expected_output="$3"
local cmd_name="${4:-$1}"
# Check command exists and is executable
if [[ ! -x "$cmd_path" ]]; then
error "unRAID command not found or not executable: $cmd_path"
error "$cmd_name may have moved or been removed — check after recent unRAID upgrade"
notify "$cmd_name not found on $(hostname) at $cmd_path — check after unRAID upgrade" "Command Validation" "warning"
return 1
fi
# Check output matches expected pattern if provided
if [[ -n "$expected_output" ]]; then
local actual_output
actual_output=$(timeout 10 "$cmd_path" $test_arg 2>&1 || true)
if ! echo "$actual_output" | grep -q "$expected_output"; then
error "$cmd_name output format changed — expected '$expected_output' not found"
error "Command may have changed after unRAID upgrade — review $cmd_path"
notify "$cmd_name output format changed on $(hostname) — may need script update after unRAID upgrade" "Command Validation" "warning"
return 1
fi
fi
log "$cmd_name validated: $cmd_path"
return 0
}
# ==============================================================================================
# ── STATUS DISPLAY ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================