audit echo vs log across all scripts — outcomes always visible, verbose for per-item loops

This commit is contained in:
Gmer4Lfe
2026-06-14 12:40:15 -04:00
parent 4c37ab16fd
commit 3964f6fb46
1010 changed files with 377767 additions and 132 deletions
@@ -0,0 +1,331 @@
#!/bin/bash
# ==============================================================================================
# =========================== Intermediate Sync Maintenance ====================================
# ==============================================================================================
# 4-hour orchestrator — arr library reconciliation, artwork fetching, and optional rsync.
# Schedule: 0 */4 * * * (every 4 hours)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. conf_sync.sh --pull-only — refresh partner conf cache in RAM (/tmp/.vv/)
# 2. arr_sync.sh — sync Lidarr/Sonarr/Radarr libraries across all nodes
# 3. Rsync window (optional) — INTERMEDIATE_SYNC_SHARES, if any configured
# 4. INTERMEDIATE_MAINTENANCE_SCRIPTS — artwork fetch and any future 4-hour jobs
#
# ── WHY A SEPARATE ORCHESTRATOR ───────────────────────────────────────────────────────────────
# arr libraries need to converge more frequently than once a day. If a remote node adds
# something at 2am, the next daily window is 23 hours away — remote arrs search for content
# they don't know is already owned. Running every 4 hours closes that gap.
#
# lidarr_missing_art.sh is idempotent — skips existing files, runs fast after initial fill.
# Pairing it here means artwork catches up within 4 hours of a new album landing.
#
# Rsync is optional — INTERMEDIATE_SYNC_SHARES empty by default. Add shares to the config
# if a subset of data needs mid-day propagation (e.g. watch state, metadata). Full media
# share sync stays in the daily window.
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID and aliases HOST*_INTERMEDIATE_SYNC_SHARES → INTERMEDIATE_SYNC_SHARES.
# Each server can have a different set of mid-day shares — configure in host*.conf.
# Each script in INTERMEDIATE_MAINTENANCE_SCRIPTS handles its own host logic.
#
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
# Same as daily_sync_maintenance.sh:
# exit 1 = temp WARN — skip this share, continue to next
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — scripts called here require root
# acquire_lock — prevents concurrent intermediate windows
# check_connectivity — verified before any rsync (skipped if no shares)
# check_remote_rootfs — aborts rsync if remote rootfs nearly full
# Non-fatal jobs — a failed arr_sync warns but does not block rsync or artwork fetch
# Silent on success — runs 4x/day, only failures warrant notification
#
# ── CONFIGURATION ─────────────────────────────────────────────────────────────────────────────
# host*.conf: HOST*_INTERMEDIATE_SYNC_SHARES — shares synced mid-day (empty = rsync skipped)
# master.conf: INTERMEDIATE_RSYNC_ENABLED — enable/disable rsync section (default: true)
# master.conf: INTERMEDIATE_MAINTENANCE_SCRIPTS — jobs run after rsync
# master.conf: ARR_SYNC_ENABLED — toggle inside arr_sync.sh
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# intermediate_sync_maintenance.sh — normal run
# intermediate_sync_maintenance.sh --dry-run — preview without changes
# intermediate_sync_maintenance.sh --log — verbose per-job output
# intermediate_sync_maintenance.sh --status — show configured shares/jobs and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Helper — run a maintenance job, track pass/fail ───────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
warn "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Interm. enabled: ${INTERMEDIATE_RSYNC_ENABLED:-true}"
echo "$ICON_GEAR Arr sync: ${ARR_SYNC_ENABLED:-true}"
echo ""
echo "━━━ Intermediate Sync Shares ━━━"
if [[ ${#INTERMEDIATE_SYNC_SHARES[@]} -eq 0 ]]; then
echo " None configured — add to INTERMEDIATE_SYNC_SHARES in master.conf to enable"
else
for share in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Intermediate Maintenance Scripts ━━━"
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
WINDOW_START=$(date +%s)
JOB_PASS=()
JOB_FAIL=()
PASS=()
FAIL=()
SHARE_TIMES=()
echo ""
echo "━━━ $ICON_GEAR Intermediate Sync — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
# ==============================================================================================
# ━━━ Conf Pull ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Conf Pull ━━━"
CONF_SYNC_SCRIPT="$SCRIPTS_ROOT/System_Essentials/conf_sync.sh"
if [[ ! -f "$CONF_SYNC_SCRIPT" ]]; then
warn "conf_sync.sh not found — skipping partner conf refresh"
else
_conf_args=("--pull-only")
[[ "$DRY_RUN" == true ]] && _conf_args+=("--dry-run")
if bash "$CONF_SYNC_SCRIPT" "${_conf_args[@]}"; then
log "Partner conf cache refreshed ✅"
JOB_PASS+=("conf_sync.sh --pull-only")
else
warn "Partner conf pull failed — cache may be stale"
JOB_FAIL+=("conf_sync.sh --pull-only")
fi
unset _conf_args
fi
# ==============================================================================================
# ━━━ Arr Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
JOB_FAIL+=("arr_sync.sh")
else
_arr_sync_args=()
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
echo "Arr sync complete ✅"
JOB_PASS+=("arr_sync.sh")
else
warn "Arr sync completed with errors — continuing"
JOB_FAIL+=("arr_sync.sh")
fi
unset _arr_sync_args
fi
# ==============================================================================================
# ━━━ Rsync (optional) ━━━
# ==============================================================================================
SHARE_COUNT=${#INTERMEDIATE_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Mid-day Share Sync — $SHARE_COUNT share(s) ━━━"
TOTAL_START=$(date +%s)
SHARE_INDEX=0
ABORT_ALL_SYNCS=false
if [[ "$SHARE_COUNT" -eq 0 ]]; then
echo "No INTERMEDIATE_SYNC_SHARES configured — skipping"
echo "Add shares to INTERMEDIATE_SYNC_SHARES in master.conf to enable mid-day sync"
elif ! check_rsync_enabled "INTERMEDIATE"; then
warn "Intermediate rsync disabled — skipping all $SHARE_COUNT share sync(s)"
else
check_connectivity
check_remote_rootfs
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for SHARE in "${INTERMEDIATE_SYNC_SHARES[@]}"; do
(( SHARE_INDEX++ ))
SHARE_NAME=$(basename "$SHARE")
SHARE_START=$(date +%s)
echo ""
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
FAIL+=("$SHARE_NAME:temp-critical")
continue
fi
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
RSYNC_EXIT=$?
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
case "$RSYNC_EXIT" in
0)
PASS+=("$SHARE_NAME")
log "$SHARE_NAME — done ✅"
;;
1)
FAIL+=("$SHARE_NAME:temp-warn")
warn "$SHARE_NAME skipped — drive temps too high"
;;
2)
FAIL+=("$SHARE_NAME:temp-critical")
ABORT_ALL_SYNCS=true
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
notify "Intermediate sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
"Intermediate Sync" "warning"
;;
*)
FAIL+=("$SHARE_NAME")
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
;;
esac
done
fi
TOTAL_END=$(date +%s)
# ==============================================================================================
# ━━━ Maintenance Jobs ━━━
# ==============================================================================================
if [[ ${#INTERMEDIATE_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Maintenance Jobs ━━━"
for script_entry in "${INTERMEDIATE_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY INTERMEDIATE SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
if [[ "$SHARE_COUNT" -gt 0 ]]; then
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
sname="${entry%%:*}"
sdur="${entry##*:}"
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
else
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
fi
done
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
echo ""
fi
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "$ICON_GEAR Jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo ""
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#JOB_PASS[@]} job(s) run, ${#PASS[@]}/$SHARE_COUNT share(s) synced"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Intermediate sync failed on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Intermediate Sync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,653 @@
#!/bin/bash
# ==============================================================================================
# ================================= Transcode Manager ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Monitors ramdisk usage and manages the transcode symlink direction. Called by
# transcode_management.sh (Orchestrators/) every 7 minutes — always after
# transcode_cleanup.sh runs first. Must be fast, non-blocking, and silent when
# nothing has changed.
#
# Emby's transcode path points at TRANSCODE_LINK (a symlink). ffmpeg resolves
# the symlink once at session start and holds a direct reference — existing
# sessions are completely unaffected by symlink flips. Only new sessions care
# where the symlink currently points.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Three Modes (TRANSCODE_MANAGER_MODE)
# smart — auto-flips between ramdisk and SSD based on usage thresholds (default)
# ramdisk above RAMDISK_WARN_GB → flip to SSD
# ramdisk below RAMDISK_LOW_GB → flip back to ramdisk
# ramdisk — always uses ramdisk, warns if above threshold, never flips
# ssd — always uses SSD, never uses ramdisk (use during drain or maintenance)
#
# Safety Checks — Every Run Regardless of Mode
# Symlink missing/broken → recreate pointing at ramdisk, notify
# Ramdisk disappeared → flip to SSD immediately, notify warning
# SSD path missing → disable SSD fallback (error if mode=ssd)
# transcoding-temp missing → recreate on ramdisk silently
# Permissions drift → fix silently every run
# Emby not running → skip threshold checks, verify symlink only
#
# Session Display
# Shows active streams from all configured TRANSCODE_SERVERS with user, title,
# type (Live TV / TV Show / Movie), and play method (Transcode / Direct).
# Split state shown when sessions exist on both ramdisk and SSD — normal during
# a flip while ramdisk sessions drain.
#
# Daily Log
# Appends one entry per run to TRANSCODE_DAILY_LOG, read by weekly_health_digest.sh.
# Format: DATE|RAMDISK_USED_GB|FLIP_COUNT|RAM_SESSION_COUNT|SSD_SESSION_COUNT|FILES_CLEANED
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Wait Lock
# acquire_lock "wait" — waits if the previous run is still active. The 7-minute
# interval can overlap on a system under heavy load.
#
# Docker Timeout
# DOCKER_TIMEOUT caps all docker calls against a hung daemon.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# Silent by Default
# Runs every 7 minutes — only speaks when something changes or needs attention.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RAMDISK_PATH / HOST*_TRANSCODE_SSD
# Ramdisk mount point and SSD fallback path.
# Aliased by detect_hosts().
#
# HOST*_RAMDISK_WARN_GB / HOST*_RAMDISK_LOW_GB / HOST*_RAMDISK_SIZE
# Thresholds and ceiling. Change all three together.
# Aliased by detect_hosts().
#
# HOST*_TRANSCODE_SERVERS
# Array of media server definitions: "ContainerName|URL|APIKey|Type"
# Type: emby | jellyfin | plex
# Aliased by detect_hosts() → TRANSCODE_SERVERS.
#
# master.conf
#
# TRANSCODE_MANAGER_MODE
# smart | ramdisk | ssd. (default: smart)
#
# TRANSCODE_CHECK_EMBY
# Skip threshold checks when Emby not running — prevents unnecessary flips
# overnight when no sessions are active. (default: true)
#
# TRANSCODE_FLIP_WARN
# Notify if symlink flips this many times in one hour — indicates ramdisk
# is undersized for the load. (default: 3)
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# /tmp/transcode_state.db — current symlink target, flip count, last flip time
# Lives in /tmp (ephemeral — resets correctly on reboot)
# TRANSCODE_DAILY_LOG — per-run append, read by weekly_health_digest.sh
# Trimmed to TRANSCODE_LOG_RETENTION days on each write
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# transcode_manager.sh
# Check usage, flip if needed, run safety checks, display active sessions.
#
# transcode_manager.sh --dry-run
# Show current usage and what flip decision would be made. No changes.
#
# transcode_manager.sh --status
# Show current symlink target, ramdisk usage, session counts, and flip history.
#
# transcode_manager.sh --log
# Verbose output including per-check results and session detail.
#
# transcode_manager.sh --no-log
# Suppress daily log write. Used internally when called by transcode_cleanup.sh.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Handle --no-log flag before parse_args
NO_LOG=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--no-log) NO_LOG=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
DOCKER_TIMEOUT=15
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock "wait"
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases all HOST*_TRANSCODE_* vars
detect_hosts
# Primary container — first entry in TRANSCODE_SERVERS
TRANSCODE_EMBY_CONTAINER="Emby"
if [[ "${#TRANSCODE_SERVERS[@]}" -gt 0 ]]; then
IFS='|' read -r PRIMARY_CONTAINER _ _ _ <<< "${TRANSCODE_SERVERS[0]}"
TRANSCODE_EMBY_CONTAINER="${PRIMARY_CONTAINER:-Emby}"
fi
case "$TRANSCODE_MANAGER_MODE" in
smart|ramdisk|ssd) log "Mode: $TRANSCODE_MANAGER_MODE" ;;
*)
warn "Unknown TRANSCODE_MANAGER_MODE: $TRANSCODE_MANAGER_MODE — defaulting to smart"
TRANSCODE_MANAGER_MODE="smart"
;;
esac
log "$ICON_GEAR Config: ramdisk=$RAMDISK_PATH size=$RAMDISK_SIZE warn-at=${RAMDISK_WARN_GB}GB flip-back-at=${RAMDISK_LOW_GB}GB ssd-min-free=${RAMDISK_SSD_MIN_GB}GB flip-warn=${TRANSCODE_FLIP_WARN}/hr"
log "$ICON_DISK SSD: $TRANSCODE_SSD"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY TRANSCODE MANAGER STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH ($RAMDISK_SIZE)"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK"
echo "$ICON_DISK SSD: $TRANSCODE_SSD"
echo "$ICON_RAM Warn at: ${RAMDISK_WARN_GB}GB"
echo "$ICON_RAM Low at: ${RAMDISK_LOW_GB}GB"
echo "$ICON_DISK SSD min free: ${RAMDISK_SSD_MIN_GB}GB"
echo "$ICON_RAM Flip warn: $TRANSCODE_FLIP_WARN per hour"
echo "$ICON_GEAR Check Emby: $TRANSCODE_CHECK_EMBY ($TRANSCODE_EMBY_CONTAINER)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "missing")
echo "$ICON_LINK Symlink now: $TRANSCODE_LINK → $CURRENT_TARGET"
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USED_GB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}')
echo "$ICON_RAM Ramdisk now: ${USED_GB}GB used"
else
echo "$ICON_RAM Ramdisk: NOT MOUNTED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
flip_symlink() {
local target="$1" reason="$2"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would flip symlink to: $target ($reason)"
return 0
fi
ln -sfn "$target" "$TRANSCODE_LINK"
warn "$ICON_LINK Symlink flipped to: $target ($reason)"
}
fix_permissions() {
local path="$1"
[[ ! -d "$path" ]] && return
chown -R "$TRANSCODE_OWNER" "$path" 2>/dev/null
chmod -R "$TRANSCODE_CHMOD" "$path" 2>/dev/null
log "Permissions fixed on $path"
}
get_ramdisk_used_gb() {
df "$RAMDISK_PATH" --output=used 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_ramdisk_avail_gb() {
df "$RAMDISK_PATH" --output=avail 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_ssd_free_gb() {
df "$TRANSCODE_SSD" --output=avail 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_flip_count() {
local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour
current_hour=$(date '+%Y-%m-%d-%H')
[[ ! -f "$state_file" ]] && echo "0" && return
local stored_hour stored_count
stored_hour=$(awk -F'|' 'NR==1{print $1}' "$state_file" 2>/dev/null)
stored_count=$(awk -F'|' 'NR==1{print $2}' "$state_file" 2>/dev/null)
[[ "$stored_hour" == "$current_hour" ]] && echo "${stored_count:-0}" || echo "0"
}
increment_flip_count() {
local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour
current_hour=$(date '+%Y-%m-%d-%H')
local current_count
current_count=$(get_flip_count)
current_count=$(( current_count + 1 ))
echo "${current_hour}|${current_count}" > "$state_file"
echo "$current_count"
}
get_media_type_label() {
case "$1" in
LiveTv|TvChannel) echo "Live TV" ;;
Episode) echo "TV Show" ;;
Movie) echo "Movie" ;;
Audio) echo "Music" ;;
MusicVideo) echo "Music Video" ;;
*) echo "$1" ;;
esac
}
get_play_method_label() {
case "$1" in
Transcode) echo "Transcode" ;;
DirectStream) echo "Direct Stream" ;;
DirectPlay) echo "Direct Play" ;;
*) echo "$1" ;;
esac
}
# ==============================================================================================
# ━━━ Transcode Manager ━━━
# ==============================================================================================
START=$(date +%s)
RAMDISK_HEALTHY=true
SSD_HEALTHY=true
EMBY_RUNNING=true
SOMETHING_HAPPENED=false # controls whether summary is printed
# ── Check 1 — Emby running ───────────────────────────────────────────────────────────────────
if [[ "$TRANSCODE_CHECK_EMBY" == true ]]; then
log "Checking $TRANSCODE_EMBY_CONTAINER..."
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$TRANSCODE_EMBY_CONTAINER" \
--format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
warn "$TRANSCODE_EMBY_CONTAINER is not running — skipping threshold checks"
EMBY_RUNNING=false
SOMETHING_HAPPENED=true
else
log "$TRANSCODE_EMBY_CONTAINER is running ✅"
fi
fi
# ── Check 2 — Symlink integrity ───────────────────────────────────────────────────────────────
log "Checking symlink integrity..."
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null)
if [[ -z "$CURRENT_TARGET" ]]; then
warn "$ICON_LINK Symlink missing — recreating pointing to ramdisk"
if [[ "$DRY_RUN" == false ]]; then
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK"
CURRENT_TARGET="$RAMDISK_PATH"
notify "Transcode symlink was missing on $(hostname) ($MY_ID) — recreated" \
"Transcode Manager" "warning"
fi
SOMETHING_HAPPENED=true
elif [[ ! -e "$CURRENT_TARGET" ]]; then
warn "$ICON_LINK Symlink target missing: $CURRENT_TARGET — resetting to ramdisk"
if [[ "$DRY_RUN" == false ]]; then
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK"
CURRENT_TARGET="$RAMDISK_PATH"
notify "Transcode symlink target was missing on $(hostname) ($MY_ID) — reset to ramdisk" \
"Transcode Manager" "warning"
fi
SOMETHING_HAPPENED=true
else
log "Symlink valid: $TRANSCODE_LINK → $CURRENT_TARGET ✅"
fi
# ── Check 3 — Ramdisk health ──────────────────────────────────────────────────────────────────
log "Checking ramdisk..."
if ! mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
error "Ramdisk not mounted at $RAMDISK_PATH"
RAMDISK_HEALTHY=false
SOMETHING_HAPPENED=true
if [[ "$DRY_RUN" == false ]]; then
warn "Flipping symlink to SSD — ramdisk unavailable"
flip_symlink "$TRANSCODE_SSD" "ramdisk disappeared"
CURRENT_TARGET="$TRANSCODE_SSD"
notify "Ramdisk disappeared on $(hostname) ($MY_ID) — transcodes falling back to SSD. Run ramdisk_setup.sh to restore." \
"Transcode Manager" "warning"
fi
else
RAMDISK_SIZE_ACTUAL=$(df "$RAMDISK_PATH" --output=size -h 2>/dev/null | tail -1 | tr -d ' ')
log "Ramdisk mounted — size: $RAMDISK_SIZE_ACTUAL ✅"
fix_permissions "$RAMDISK_PATH"
# Guarantee transcoding-temp exists on ramdisk
TRANSCODE_TEMP_RAM="${RAMDISK_PATH}/transcoding-temp"
if [[ ! -d "$TRANSCODE_TEMP_RAM" ]]; then
warn "transcoding-temp missing from ramdisk — creating now"
mkdir -p "$TRANSCODE_TEMP_RAM"
chmod "$TRANSCODE_CHMOD" "$TRANSCODE_TEMP_RAM"
chown "$TRANSCODE_OWNER" "$TRANSCODE_TEMP_RAM"
warn "transcoding-temp created on ramdisk — new sessions will use ramdisk ✅"
SOMETHING_HAPPENED=true
else
log "transcoding-temp exists on ramdisk ✅"
fi
fi
# ── Check 4 — SSD health ─────────────────────────────────────────────────────────────────────
log "Checking SSD fallback..."
if [[ ! -d "$TRANSCODE_SSD" ]]; then
warn "SSD fallback path missing: $TRANSCODE_SSD"
SSD_HEALTHY=false
SOMETHING_HAPPENED=true
if [[ "$TRANSCODE_MANAGER_MODE" == "ssd" ]]; then
error "Mode is 'ssd' but SSD path is missing — cannot continue"
notify "Transcode SSD path missing on $(hostname) ($MY_ID) — mode is 'ssd', manual intervention needed" \
"Transcode Manager" "warning"
exit 1
else
warn "SSD fallback disabled — will stay on ramdisk"
fi
else
SSD_FREE_GB=$(get_ssd_free_gb)
log "SSD available — ${SSD_FREE_GB}GB free ✅"
fix_permissions "$TRANSCODE_SSD"
fi
# ── Usage stats ───────────────────────────────────────────────────────────────────────────────
RAMDISK_USED_GB="0.00"
RAMDISK_AVAIL_GB="0.00"
RAMDISK_FILES=0
if [[ "$RAMDISK_HEALTHY" == true ]]; then
RAMDISK_USED_GB=$(get_ramdisk_used_gb)
RAMDISK_AVAIL_GB=$(get_ramdisk_avail_gb)
RAMDISK_FILES=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l)
fi
SSD_FILES=$(find "$TRANSCODE_SSD" -type f 2>/dev/null | wc -l)
FLIP_COUNT=$(get_flip_count)
log "Ramdisk: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available ($RAMDISK_FILES files)"
log "SSD: $SSD_FILES files"
log "Flips: $FLIP_COUNT this hour"
# ==============================================================================================
# ━━━ Active Transcode Sessions ━━━
# ==============================================================================================
TOTAL_SESSIONS=0
LIVE_TV=0
TRANSCODING=0
DIRECT=0
ANY_SERVER_RUNNING=false
RAM_SESSION_COUNT=0
SSD_SESSION_COUNT=0
for server_entry in "${TRANSCODE_SERVERS[@]}"; do
IFS='|' read -r SRV_CONTAINER SRV_URL SRV_KEY SRV_TYPE <<< "$server_entry"
# Skip placeholder entries
if [[ "$SRV_KEY" == *"api-key"* ]] || \
[[ "$SRV_KEY" == *"token"* && ${#SRV_KEY} -lt 20 ]]; then
log "Skipping $SRV_CONTAINER — placeholder API key"
continue
fi
# Container running check with timeout
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$SRV_CONTAINER" \
--format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
log "$SRV_CONTAINER — not running, skipping"
continue
fi
ANY_SERVER_RUNNING=true
if ! check_api "$SRV_URL" "$SRV_CONTAINER" 5; then
warn "$SRV_CONTAINER — API unreachable, skipping"
continue
fi
case "$SRV_TYPE" in
emby|jellyfin)
SESSION_DATA=$(curl -sf --max-time 10 \
-H "X-Emby-Token: $SRV_KEY" \
"${SRV_URL}/Sessions" 2>/dev/null) ;;
plex)
SESSION_DATA=$(curl -sf --max-time 10 \
-H "X-Plex-Token: $SRV_KEY" \
"${SRV_URL}/status/sessions" 2>/dev/null) ;;
*)
warn "Unknown server type '$SRV_TYPE' for $SRV_CONTAINER — skipping"
continue ;;
esac
[[ -z "$SESSION_DATA" ]] && continue
! command -v jq >/dev/null 2>&1 && continue
if [[ "$SRV_TYPE" == "emby" || "$SRV_TYPE" == "jellyfin" ]]; then
SRV_TOTAL=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null)] | length' 2>/dev/null || echo 0)
SRV_LIVE=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.NowPlayingItem.Type == "LiveTv" or
.NowPlayingItem.Type == "TvChannel")] | length' \
2>/dev/null || echo 0)
SRV_TRANSCODE=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.PlayState.PlayMethod == "Transcode")] | length' \
2>/dev/null || echo 0)
SRV_DIRECT=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.PlayState.PlayMethod != "Transcode")] | length' \
2>/dev/null || echo 0)
TOTAL_SESSIONS=$(( TOTAL_SESSIONS + SRV_TOTAL ))
LIVE_TV=$(( LIVE_TV + SRV_LIVE ))
TRANSCODING=$(( TRANSCODING + SRV_TRANSCODE ))
DIRECT=$(( DIRECT + SRV_DIRECT ))
if [[ "$SRV_TOTAL" -gt 0 ]]; then
SOMETHING_HAPPENED=true
[[ "${#TRANSCODE_SERVERS[@]}" -gt 1 ]] && \
echo " $ICON_EMBY $SRV_CONTAINER — $SRV_TOTAL streams"
while IFS= read -r session; do
USER=$(echo "$session" | jq -r '.UserName // "Unknown"' 2>/dev/null)
TITLE=$(echo "$session" | jq -r '.NowPlayingItem.Name // "Unknown"' 2>/dev/null)
MTYPE=$(echo "$session" | jq -r '.NowPlayingItem.Type // "Unknown"' 2>/dev/null)
METH=$(echo "$session" | jq -r '.PlayState.PlayMethod // "Unknown"' 2>/dev/null)
echo " $ICON_EMBY $(printf '%-12s' "$USER") — $(printf '%-30s' "$TITLE") — $(get_media_type_label "$MTYPE") — $(get_play_method_label "$METH")"
done < <(echo "$SESSION_DATA" | jq -c '.[] | select(.NowPlayingItem != null)' 2>/dev/null)
fi
fi
done
# Storage state
RAM_SESSION_COUNT=$(find "$RAMDISK_PATH/transcoding-temp" \
-mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
SSD_SESSION_COUNT=$(find "$TRANSCODE_SSD/transcoding-temp" \
-mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
if [[ "$TOTAL_SESSIONS" -gt 0 ]]; then
echo ""
echo " $ICON_EMBY Total: $TOTAL_SESSIONS | Live TV: $LIVE_TV | Transcoding: $TRANSCODING | Direct: $DIRECT"
if [[ "$RAM_SESSION_COUNT" -gt 0 && "$SSD_SESSION_COUNT" -gt 0 ]]; then
warn "Split state — $RAM_SESSION_COUNT folder(s) ramdisk / $SSD_SESSION_COUNT SSD"
warn "Older sessions remain on original location until they end naturally"
elif [[ "$RAM_SESSION_COUNT" -gt 0 ]]; then
log "Storage: ramdisk ($RAM_SESSION_COUNT sessions)"
elif [[ "$SSD_SESSION_COUNT" -gt 0 ]]; then
log "Storage: SSD ($SSD_SESSION_COUNT sessions)"
fi
fi
[[ "$ANY_SERVER_RUNNING" == false && "$TRANSCODE_CHECK_EMBY" == true ]] && \
{ log "No configured media servers running — skipping threshold checks"; EMBY_RUNNING=false; }
# ==============================================================================================
# ━━━ Mode Logic ━━━
# ==============================================================================================
case "$TRANSCODE_MANAGER_MODE" in
ramdisk)
log "Mode: RAMDISK — forcing symlink to ramdisk"
if [[ "$RAMDISK_HEALTHY" == false ]]; then
error "Ramdisk mode selected but ramdisk is not available"
notify "Transcode ramdisk mode failed on $(hostname) ($MY_ID) — ramdisk not mounted" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
else
if [[ "$CURRENT_TARGET" != "$RAMDISK_PATH" ]]; then
flip_symlink "$RAMDISK_PATH" "ramdisk mode"
increment_flip_count > /dev/null
FLIP_COUNT=$(get_flip_count)
SOMETHING_HAPPENED=true
fi
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then
warn "Ramdisk usage ${RAMDISK_USED_GB}GB above threshold ${RAMDISK_WARN_GB}GB — consider switching to smart mode"
notify "Ramdisk high on $(hostname) ($MY_ID) — ${RAMDISK_USED_GB}GB in ramdisk-only mode" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
fi
fi
;;
ssd)
log "Mode: SSD — forcing symlink to SSD"
if [[ "$SSD_HEALTHY" == false ]]; then
error "SSD mode selected but SSD path is not available"
SOMETHING_HAPPENED=true
else
if [[ "$CURRENT_TARGET" != "$TRANSCODE_SSD" ]]; then
flip_symlink "$TRANSCODE_SSD" "ssd mode"
increment_flip_count > /dev/null
FLIP_COUNT=$(get_flip_count)
SOMETHING_HAPPENED=true
fi
fi
;;
smart)
log "Mode: SMART — auto threshold management"
if [[ "$EMBY_RUNNING" == false ]]; then
log "No media servers running — skipping threshold checks"
elif [[ "$RAMDISK_HEALTHY" == false ]]; then
log "Ramdisk unavailable — staying on SSD until ramdisk recovers"
elif [[ "$CURRENT_TARGET" == "$RAMDISK_PATH" ]]; then
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then
if [[ "$SSD_HEALTHY" == false ]]; then
error "Ramdisk above threshold but SSD unavailable — cannot flip"
notify "Transcode ramdisk full on $(hostname) ($MY_ID) and SSD unavailable" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
else
SSD_FREE_GB=$(get_ssd_free_gb)
if (( $(awk "BEGIN {print ($SSD_FREE_GB < $RAMDISK_SSD_MIN_GB) ? 1 : 0}") )); then
warn "SSD only ${SSD_FREE_GB}GB free — below ${RAMDISK_SSD_MIN_GB}GB minimum, not flipping"
SOMETHING_HAPPENED=true
else
warn "Ramdisk ${RAMDISK_USED_GB}GB — above ${RAMDISK_WARN_GB}GB, flipping to SSD"
flip_symlink "$TRANSCODE_SSD" "threshold exceeded"
CURRENT_TARGET="$TRANSCODE_SSD"
NEW_COUNT=$(increment_flip_count)
FLIP_COUNT=$NEW_COUNT
SOMETHING_HAPPENED=true
if [[ "$NEW_COUNT" -ge "$TRANSCODE_FLIP_WARN" ]]; then
notify "Transcode flipped to SSD on $(hostname) ($MY_ID) — ${NEW_COUNT} flips this hour — consider increasing HOST*_RAMDISK_SIZE" \
"Transcode Manager" "warning"
fi
fi
fi
else
log "Ramdisk ${RAMDISK_USED_GB}GB — below threshold — no action needed"
fi
elif [[ "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB <= $RAMDISK_LOW_GB) ? 1 : 0}") )); then
warn "Ramdisk ${RAMDISK_USED_GB}GB — below ${RAMDISK_LOW_GB}GB, flipping back to ramdisk"
flip_symlink "$RAMDISK_PATH" "usage recovered"
CURRENT_TARGET="$RAMDISK_PATH"
NEW_COUNT=$(increment_flip_count)
FLIP_COUNT=$NEW_COUNT
SOMETHING_HAPPENED=true
else
log "On SSD — ramdisk ${RAMDISK_USED_GB}GB still above low threshold ${RAMDISK_LOW_GB}GB"
fi
fi
;;
esac
END=$(date +%s)
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown")
# ==============================================================================================
# ━━━ Daily Log Write ━━━
# ==============================================================================================
# Read by weekly_health_digest.sh — format: DATE|USED_GB|FLIPS|RAM_SESSIONS|SSD_SESSIONS
if [[ "$DRY_RUN" == false && "$NO_LOG" == false && -n "${TRANSCODE_DAILY_LOG:-}" ]]; then
TODAY=$(date '+%Y-%m-%d')
mkdir -p "$(dirname "$TRANSCODE_DAILY_LOG")"
echo "${TODAY}|${RAMDISK_USED_GB}|${FLIP_COUNT}|${RAM_SESSION_COUNT}|${SSD_SESSION_COUNT}" \
>> "$TRANSCODE_DAILY_LOG" 2>/dev/null || true
log "Daily log written: $TRANSCODE_DAILY_LOG"
fi
# ==============================================================================================
# ━━━ Summary — only shown when something happened ━━━
# ==============================================================================================
if [[ "$SOMETHING_HAPPENED" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY TRANSCODE MANAGER SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE"
echo "$ICON_RAM Ramdisk: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK → $CURRENT_TARGET"
echo "$ICON_LINK Flips: $FLIP_COUNT this hour (warn at $TRANSCODE_FLIP_WARN)"
echo "$ICON_EMBY Streams: $TOTAL_SESSIONS total | Live TV: $LIVE_TV | Transcoding: $TRANSCODING | Direct: $DIRECT"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
else
echo "Transcode manager — all healthy — $MY_ID ($(format_duration $(( END - START ))))"
fi
@@ -0,0 +1,662 @@
#!/bin/bash
# ==============================================================================================
# ================================= Transcode Manager ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Monitors ramdisk usage and manages the transcode symlink direction. Called by
# transcode_management.sh (Orchestrators/) every 7 minutes — always after
# transcode_cleanup.sh runs first. Must be fast, non-blocking, and silent when
# nothing has changed.
#
# Emby's transcode path points at TRANSCODE_LINK (a symlink). ffmpeg resolves
# the symlink once at session start and holds a direct reference — existing
# sessions are completely unaffected by symlink flips. Only new sessions care
# where the symlink currently points.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Three Modes (TRANSCODE_MANAGER_MODE)
# smart — auto-flips between ramdisk and SSD based on usage thresholds (default)
# ramdisk above RAMDISK_WARN_GB → flip to SSD
# ramdisk below RAMDISK_LOW_GB → flip back to ramdisk
# ramdisk — always uses ramdisk, warns if above threshold, never flips
# ssd — always uses SSD, never uses ramdisk (use during drain or maintenance)
#
# Safety Checks — Every Run Regardless of Mode
# Symlink missing/broken → recreate pointing at ramdisk, notify
# Ramdisk disappeared → flip to SSD immediately, notify warning
# SSD path missing → disable SSD fallback (error if mode=ssd)
# transcoding-temp missing → recreate on ramdisk silently
# Permissions drift → fix silently every run
# Emby not running → skip threshold checks, verify symlink only
#
# Session Display
# Shows active streams from all configured TRANSCODE_SERVERS with user, title,
# type (Live TV / TV Show / Movie), and play method (Transcode / Direct).
# Split state shown when sessions exist on both ramdisk and SSD — normal during
# a flip while ramdisk sessions drain.
#
# Daily Log
# Appends one entry per run to TRANSCODE_DAILY_LOG, read by weekly_health_digest.sh.
# Format: DATE|RAMDISK_USED_GB|FLIP_COUNT|RAM_SESSION_COUNT|SSD_SESSION_COUNT|FILES_CLEANED
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Wait Lock
# acquire_lock "wait" — waits if the previous run is still active. The 7-minute
# interval can overlap on a system under heavy load.
#
# Docker Timeout
# DOCKER_TIMEOUT caps all docker calls against a hung daemon.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# Silent by Default
# Runs every 7 minutes — only speaks when something changes or needs attention.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RAMDISK_PATH / HOST*_TRANSCODE_SSD
# Ramdisk mount point and SSD fallback path.
# Aliased by detect_hosts().
#
# HOST*_RAMDISK_WARN_GB / HOST*_RAMDISK_LOW_GB / HOST*_RAMDISK_SIZE
# Thresholds and ceiling. Change all three together.
# Aliased by detect_hosts().
#
# HOST*_TRANSCODE_SERVERS
# Array of media server definitions: "ContainerName|URL|APIKey|Type"
# Type: emby | jellyfin | plex
# Aliased by detect_hosts() → TRANSCODE_SERVERS.
#
# master.conf
#
# TRANSCODE_MANAGER_MODE
# smart | ramdisk | ssd. (default: smart)
#
# TRANSCODE_CHECK_EMBY
# Skip threshold checks when Emby not running — prevents unnecessary flips
# overnight when no sessions are active. (default: true)
#
# TRANSCODE_FLIP_WARN
# Notify if symlink flips this many times in one hour — indicates ramdisk
# is undersized for the load. (default: 3)
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# /tmp/transcode_state.db — current symlink target, flip count, last flip time
# Lives in /tmp (ephemeral — resets correctly on reboot)
# TRANSCODE_DAILY_LOG — per-run append, read by weekly_health_digest.sh
# Trimmed to TRANSCODE_LOG_RETENTION days on each write
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# transcode_manager.sh
# Check usage, flip if needed, run safety checks, display active sessions.
#
# transcode_manager.sh --dry-run
# Show current usage and what flip decision would be made. No changes.
#
# transcode_manager.sh --status
# Show current symlink target, ramdisk usage, session counts, and flip history.
#
# transcode_manager.sh --log
# Verbose output including per-check results and session detail.
#
# transcode_manager.sh --no-log
# Suppress daily log write. Used internally when called by transcode_cleanup.sh.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Handle --no-log flag before parse_args
NO_LOG=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--no-log) NO_LOG=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
DOCKER_TIMEOUT=15
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock "wait"
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases all HOST*_TRANSCODE_* vars
detect_hosts
# Primary container — first entry in TRANSCODE_SERVERS
TRANSCODE_EMBY_CONTAINER="Emby"
if [[ "${#TRANSCODE_SERVERS[@]}" -gt 0 ]]; then
IFS='|' read -r PRIMARY_CONTAINER _ _ _ <<< "${TRANSCODE_SERVERS[0]}"
TRANSCODE_EMBY_CONTAINER="${PRIMARY_CONTAINER:-Emby}"
fi
case "$TRANSCODE_MANAGER_MODE" in
smart|ramdisk|ssd) log "Mode: $TRANSCODE_MANAGER_MODE" ;;
*)
warn "Unknown TRANSCODE_MANAGER_MODE: $TRANSCODE_MANAGER_MODE — defaulting to smart"
TRANSCODE_MANAGER_MODE="smart"
;;
esac
log "$ICON_GEAR Config: ramdisk=$RAMDISK_PATH size=$RAMDISK_SIZE warn-at=${RAMDISK_WARN_GB}GB flip-back-at=${RAMDISK_LOW_GB}GB ssd-min-free=${RAMDISK_SSD_MIN_GB}GB flip-warn=${TRANSCODE_FLIP_WARN}/hr"
log "$ICON_DISK SSD: $TRANSCODE_SSD"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY TRANSCODE MANAGER STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH ($RAMDISK_SIZE)"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK"
echo "$ICON_DISK SSD: $TRANSCODE_SSD"
echo "$ICON_RAM Warn at: ${RAMDISK_WARN_GB}GB"
echo "$ICON_RAM Low at: ${RAMDISK_LOW_GB}GB"
echo "$ICON_DISK SSD min free: ${RAMDISK_SSD_MIN_GB}GB"
echo "$ICON_RAM Flip warn: $TRANSCODE_FLIP_WARN per hour"
echo "$ICON_GEAR Check Emby: $TRANSCODE_CHECK_EMBY ($TRANSCODE_EMBY_CONTAINER)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "missing")
echo "$ICON_LINK Symlink now: $TRANSCODE_LINK → $CURRENT_TARGET"
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USED_GB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}')
echo "$ICON_RAM Ramdisk now: ${USED_GB}GB used"
else
echo "$ICON_RAM Ramdisk: NOT MOUNTED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
flip_symlink() {
local target="$1" reason="$2"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would flip symlink to: $target ($reason)"
return 0
fi
ln -sfn "$target" "$TRANSCODE_LINK"
warn "$ICON_LINK Symlink flipped to: $target ($reason)"
}
fix_permissions() {
local path="$1"
[[ ! -d "$path" ]] && return
chown -R "$TRANSCODE_OWNER" "$path" 2>/dev/null
chmod -R "$TRANSCODE_CHMOD" "$path" 2>/dev/null
log "Permissions fixed on $path"
}
get_ramdisk_used_gb() {
df "$RAMDISK_PATH" --output=used 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_ramdisk_avail_gb() {
df "$RAMDISK_PATH" --output=avail 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_ssd_free_gb() {
df "$TRANSCODE_SSD" --output=avail 2>/dev/null | \
tail -1 | tr -d ' ' | awk '{printf "%.2f", $1/1048576}'
}
get_flip_count() {
local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour
current_hour=$(date '+%Y-%m-%d-%H')
[[ ! -f "$state_file" ]] && echo "0" && return
local stored_hour stored_count
stored_hour=$(awk -F'|' 'NR==1{print $1}' "$state_file" 2>/dev/null)
stored_count=$(awk -F'|' 'NR==1{print $2}' "$state_file" 2>/dev/null)
[[ "$stored_hour" == "$current_hour" ]] && echo "${stored_count:-0}" || echo "0"
}
increment_flip_count() {
local state_file="${STATE_DIR:-/tmp}/transcode_flip_state.db"
local current_hour
current_hour=$(date '+%Y-%m-%d-%H')
local current_count
current_count=$(get_flip_count)
current_count=$(( current_count + 1 ))
echo "${current_hour}|${current_count}" > "$state_file"
echo "$current_count"
}
get_media_type_label() {
case "$1" in
LiveTv|TvChannel) echo "Live TV" ;;
Episode) echo "TV Show" ;;
Movie) echo "Movie" ;;
Audio) echo "Music" ;;
MusicVideo) echo "Music Video" ;;
*) echo "$1" ;;
esac
}
get_play_method_label() {
case "$1" in
Transcode) echo "Transcode" ;;
DirectStream) echo "Direct Stream" ;;
DirectPlay) echo "Direct Play" ;;
*) echo "$1" ;;
esac
}
# ==============================================================================================
# ━━━ Transcode Manager ━━━
# ==============================================================================================
START=$(date +%s)
RAMDISK_HEALTHY=true
SSD_HEALTHY=true
EMBY_RUNNING=true
SOMETHING_HAPPENED=false # controls whether summary is printed
# ── Check 1 — Emby running ───────────────────────────────────────────────────────────────────
if [[ "$TRANSCODE_CHECK_EMBY" == true ]]; then
log "Checking $TRANSCODE_EMBY_CONTAINER..."
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$TRANSCODE_EMBY_CONTAINER" \
--format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
warn "$TRANSCODE_EMBY_CONTAINER is not running — skipping threshold checks"
EMBY_RUNNING=false
SOMETHING_HAPPENED=true
else
log "$TRANSCODE_EMBY_CONTAINER is running ✅"
fi
fi
# ── Check 2 — Symlink integrity ───────────────────────────────────────────────────────────────
log "Checking symlink integrity..."
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null)
if [[ -z "$CURRENT_TARGET" ]]; then
warn "$ICON_LINK Symlink missing — recreating pointing to ramdisk"
if [[ "$DRY_RUN" == false ]]; then
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK"
CURRENT_TARGET="$RAMDISK_PATH"
notify "Transcode symlink was missing on $(hostname) ($MY_ID) — recreated" \
"Transcode Manager" "warning"
fi
SOMETHING_HAPPENED=true
elif [[ ! -e "$CURRENT_TARGET" ]]; then
warn "$ICON_LINK Symlink target missing: $CURRENT_TARGET — resetting to ramdisk"
if [[ "$DRY_RUN" == false ]]; then
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK"
CURRENT_TARGET="$RAMDISK_PATH"
notify "Transcode symlink target was missing on $(hostname) ($MY_ID) — reset to ramdisk" \
"Transcode Manager" "warning"
fi
SOMETHING_HAPPENED=true
else
log "Symlink valid: $TRANSCODE_LINK → $CURRENT_TARGET ✅"
fi
# ── Check 3 — Ramdisk health ──────────────────────────────────────────────────────────────────
log "Checking ramdisk..."
if ! mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
error "Ramdisk not mounted at $RAMDISK_PATH"
RAMDISK_HEALTHY=false
SOMETHING_HAPPENED=true
if [[ "$DRY_RUN" == false ]]; then
warn "Flipping symlink to SSD — ramdisk unavailable"
flip_symlink "$TRANSCODE_SSD" "ramdisk disappeared"
CURRENT_TARGET="$TRANSCODE_SSD"
notify "Ramdisk disappeared on $(hostname) ($MY_ID) — transcodes falling back to SSD. Run ramdisk_setup.sh to restore." \
"Transcode Manager" "warning"
fi
else
RAMDISK_SIZE_ACTUAL=$(df "$RAMDISK_PATH" --output=size -h 2>/dev/null | tail -1 | tr -d ' ')
log "Ramdisk mounted — size: $RAMDISK_SIZE_ACTUAL ✅"
fix_permissions "$RAMDISK_PATH"
# Guarantee transcoding-temp exists on ramdisk
TRANSCODE_TEMP_RAM="${RAMDISK_PATH}/transcoding-temp"
if [[ ! -d "$TRANSCODE_TEMP_RAM" ]]; then
warn "transcoding-temp missing from ramdisk — creating now"
mkdir -p "$TRANSCODE_TEMP_RAM"
chmod "$TRANSCODE_CHMOD" "$TRANSCODE_TEMP_RAM"
chown "$TRANSCODE_OWNER" "$TRANSCODE_TEMP_RAM"
warn "transcoding-temp created on ramdisk — new sessions will use ramdisk ✅"
SOMETHING_HAPPENED=true
else
log "transcoding-temp exists on ramdisk ✅"
fi
fi
# ── Check 4 — SSD health ─────────────────────────────────────────────────────────────────────
log "Checking SSD fallback..."
if [[ ! -d "$TRANSCODE_SSD" ]]; then
warn "SSD fallback path missing: $TRANSCODE_SSD"
SSD_HEALTHY=false
SOMETHING_HAPPENED=true
if [[ "$TRANSCODE_MANAGER_MODE" == "ssd" ]]; then
error "Mode is 'ssd' but SSD path is missing — cannot continue"
notify "Transcode SSD path missing on $(hostname) ($MY_ID) — mode is 'ssd', manual intervention needed" \
"Transcode Manager" "warning"
exit 1
else
warn "SSD fallback disabled — will stay on ramdisk"
fi
else
SSD_FREE_GB=$(get_ssd_free_gb)
log "SSD available — ${SSD_FREE_GB}GB free ✅"
fix_permissions "$TRANSCODE_SSD"
fi
# ── Usage stats ───────────────────────────────────────────────────────────────────────────────
RAMDISK_USED_GB="0.00"
RAMDISK_AVAIL_GB="0.00"
RAMDISK_FILES=0
if [[ "$RAMDISK_HEALTHY" == true ]]; then
RAMDISK_USED_GB=$(get_ramdisk_used_gb)
RAMDISK_AVAIL_GB=$(get_ramdisk_avail_gb)
RAMDISK_FILES=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l)
fi
SSD_FILES=$(find "$TRANSCODE_SSD" -type f 2>/dev/null | wc -l)
FLIP_COUNT=$(get_flip_count)
log "Ramdisk: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available ($RAMDISK_FILES files)"
log "SSD: $SSD_FILES files"
log "Flips: $FLIP_COUNT this hour"
# ==============================================================================================
# ━━━ Active Transcode Sessions ━━━
# ==============================================================================================
TOTAL_SESSIONS=0
LIVE_TV=0
TRANSCODING=0
DIRECT=0
ANY_SERVER_RUNNING=false
RAM_SESSION_COUNT=0
SSD_SESSION_COUNT=0
for server_entry in "${TRANSCODE_SERVERS[@]}"; do
IFS='|' read -r SRV_CONTAINER SRV_URL SRV_KEY SRV_TYPE <<< "$server_entry"
# Skip placeholder entries
if [[ "$SRV_KEY" == *"api-key"* ]] || \
[[ "$SRV_KEY" == *"token"* && ${#SRV_KEY} -lt 20 ]]; then
log "Skipping $SRV_CONTAINER — placeholder API key"
continue
fi
# Container running check with timeout
if ! timeout "$DOCKER_TIMEOUT" docker inspect "$SRV_CONTAINER" \
--format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
log "$SRV_CONTAINER — not running, skipping"
continue
fi
ANY_SERVER_RUNNING=true
if ! check_api "$SRV_URL" "$SRV_CONTAINER" 5; then
warn "$SRV_CONTAINER — API unreachable, skipping"
continue
fi
case "$SRV_TYPE" in
emby|jellyfin)
SESSION_DATA=$(curl -sf --max-time 10 \
-H "X-Emby-Token: $SRV_KEY" \
"${SRV_URL}/Sessions" 2>/dev/null) ;;
plex)
SESSION_DATA=$(curl -sf --max-time 10 \
-H "X-Plex-Token: $SRV_KEY" \
"${SRV_URL}/status/sessions" 2>/dev/null) ;;
*)
warn "Unknown server type '$SRV_TYPE' for $SRV_CONTAINER — skipping"
continue ;;
esac
[[ -z "$SESSION_DATA" ]] && continue
! command -v jq >/dev/null 2>&1 && continue
if [[ "$SRV_TYPE" == "emby" || "$SRV_TYPE" == "jellyfin" ]]; then
SRV_TOTAL=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null)] | length' 2>/dev/null || echo 0)
SRV_LIVE=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.NowPlayingItem.Type == "LiveTv" or
.NowPlayingItem.Type == "TvChannel")] | length' \
2>/dev/null || echo 0)
SRV_TRANSCODE=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.PlayState.PlayMethod == "Transcode")] | length' \
2>/dev/null || echo 0)
SRV_DIRECT=$(echo "$SESSION_DATA" | \
jq '[.[] | select(.NowPlayingItem != null) |
select(.PlayState.PlayMethod != "Transcode")] | length' \
2>/dev/null || echo 0)
TOTAL_SESSIONS=$(( TOTAL_SESSIONS + SRV_TOTAL ))
LIVE_TV=$(( LIVE_TV + SRV_LIVE ))
TRANSCODING=$(( TRANSCODING + SRV_TRANSCODE ))
DIRECT=$(( DIRECT + SRV_DIRECT ))
if [[ "$SRV_TOTAL" -gt 0 ]]; then
SOMETHING_HAPPENED=true
[[ "${#TRANSCODE_SERVERS[@]}" -gt 1 ]] && \
echo " $ICON_EMBY $SRV_CONTAINER — $SRV_TOTAL streams"
while IFS= read -r session; do
USER=$(echo "$session" | jq -r '.UserName // "Unknown"' 2>/dev/null)
TITLE=$(echo "$session" | jq -r '.NowPlayingItem.Name // "Unknown"' 2>/dev/null)
MTYPE=$(echo "$session" | jq -r '.NowPlayingItem.Type // "Unknown"' 2>/dev/null)
METH=$(echo "$session" | jq -r '.PlayState.PlayMethod // "Unknown"' 2>/dev/null)
echo " $ICON_EMBY $(printf '%-12s' "$USER") — $(printf '%-30s' "$TITLE") — $(get_media_type_label "$MTYPE") — $(get_play_method_label "$METH")"
done < <(echo "$SESSION_DATA" | jq -c '.[] | select(.NowPlayingItem != null)' 2>/dev/null)
fi
fi
done
# Storage state
# Sessions may use subdirectories (legacy transcode) or flat files (Live TV / Direct Stream HLS).
# Count both: subdirs per-session, and unique hex session ID prefixes for flat files.
_count_sessions() {
local base="$1/transcoding-temp"
local _dirs _flat
_dirs=$(find "$base" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
_flat=$(find "$base" -maxdepth 1 -type f -printf '%f\n' 2>/dev/null | \
grep -oE '^[0-9a-f]{16,}' | sort -u | wc -l)
echo $(( _dirs + _flat ))
}
RAM_SESSION_COUNT=$(_count_sessions "$RAMDISK_PATH")
SSD_SESSION_COUNT=$(_count_sessions "$TRANSCODE_SSD")
unset -f _count_sessions
if [[ "$TOTAL_SESSIONS" -gt 0 ]]; then
echo ""
echo " $ICON_EMBY Total: $TOTAL_SESSIONS | Live TV: $LIVE_TV | Transcoding: $TRANSCODING | Direct: $DIRECT"
if [[ "$RAM_SESSION_COUNT" -gt 0 && "$SSD_SESSION_COUNT" -gt 0 ]]; then
warn "Split state — $RAM_SESSION_COUNT session(s) ramdisk / $SSD_SESSION_COUNT SSD"
warn "Older sessions remain on original location until they end naturally"
elif [[ "$RAM_SESSION_COUNT" -gt 0 ]]; then
log "Storage: ramdisk ($RAM_SESSION_COUNT session(s))"
elif [[ "$SSD_SESSION_COUNT" -gt 0 ]]; then
log "Storage: SSD ($SSD_SESSION_COUNT session(s))"
fi
fi
[[ "$ANY_SERVER_RUNNING" == false && "$TRANSCODE_CHECK_EMBY" == true ]] && \
{ log "No configured media servers running — skipping threshold checks"; EMBY_RUNNING=false; }
# ==============================================================================================
# ━━━ Mode Logic ━━━
# ==============================================================================================
case "$TRANSCODE_MANAGER_MODE" in
ramdisk)
log "Mode: RAMDISK — forcing symlink to ramdisk"
if [[ "$RAMDISK_HEALTHY" == false ]]; then
error "Ramdisk mode selected but ramdisk is not available"
notify "Transcode ramdisk mode failed on $(hostname) ($MY_ID) — ramdisk not mounted" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
else
if [[ "$CURRENT_TARGET" != "$RAMDISK_PATH" ]]; then
flip_symlink "$RAMDISK_PATH" "ramdisk mode"
increment_flip_count > /dev/null
FLIP_COUNT=$(get_flip_count)
SOMETHING_HAPPENED=true
fi
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then
warn "Ramdisk usage ${RAMDISK_USED_GB}GB above threshold ${RAMDISK_WARN_GB}GB — consider switching to smart mode"
notify "Ramdisk high on $(hostname) ($MY_ID) — ${RAMDISK_USED_GB}GB in ramdisk-only mode" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
fi
fi
;;
ssd)
log "Mode: SSD — forcing symlink to SSD"
if [[ "$SSD_HEALTHY" == false ]]; then
error "SSD mode selected but SSD path is not available"
SOMETHING_HAPPENED=true
else
if [[ "$CURRENT_TARGET" != "$TRANSCODE_SSD" ]]; then
flip_symlink "$TRANSCODE_SSD" "ssd mode"
increment_flip_count > /dev/null
FLIP_COUNT=$(get_flip_count)
SOMETHING_HAPPENED=true
fi
fi
;;
smart)
log "Mode: SMART — auto threshold management"
if [[ "$EMBY_RUNNING" == false ]]; then
log "No media servers running — skipping threshold checks"
elif [[ "$RAMDISK_HEALTHY" == false ]]; then
log "Ramdisk unavailable — staying on SSD until ramdisk recovers"
elif [[ "$CURRENT_TARGET" == "$RAMDISK_PATH" ]]; then
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB >= $RAMDISK_WARN_GB) ? 1 : 0}") )); then
if [[ "$SSD_HEALTHY" == false ]]; then
error "Ramdisk above threshold but SSD unavailable — cannot flip"
notify "Transcode ramdisk full on $(hostname) ($MY_ID) and SSD unavailable" \
"Transcode Manager" "warning"
SOMETHING_HAPPENED=true
else
SSD_FREE_GB=$(get_ssd_free_gb)
if (( $(awk "BEGIN {print ($SSD_FREE_GB < $RAMDISK_SSD_MIN_GB) ? 1 : 0}") )); then
warn "SSD only ${SSD_FREE_GB}GB free — below ${RAMDISK_SSD_MIN_GB}GB minimum, not flipping"
SOMETHING_HAPPENED=true
else
warn "Ramdisk ${RAMDISK_USED_GB}GB — above ${RAMDISK_WARN_GB}GB, flipping to SSD"
flip_symlink "$TRANSCODE_SSD" "threshold exceeded"
CURRENT_TARGET="$TRANSCODE_SSD"
NEW_COUNT=$(increment_flip_count)
FLIP_COUNT=$NEW_COUNT
SOMETHING_HAPPENED=true
if [[ "$NEW_COUNT" -ge "$TRANSCODE_FLIP_WARN" ]]; then
notify "Transcode flipped to SSD on $(hostname) ($MY_ID) — ${NEW_COUNT} flips this hour — consider increasing HOST*_RAMDISK_SIZE" \
"Transcode Manager" "warning"
fi
fi
fi
else
log "Ramdisk ${RAMDISK_USED_GB}GB — below threshold — no action needed"
fi
elif [[ "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then
if (( $(awk "BEGIN {print ($RAMDISK_USED_GB <= $RAMDISK_LOW_GB) ? 1 : 0}") )); then
warn "Ramdisk ${RAMDISK_USED_GB}GB — below ${RAMDISK_LOW_GB}GB, flipping back to ramdisk"
flip_symlink "$RAMDISK_PATH" "usage recovered"
CURRENT_TARGET="$RAMDISK_PATH"
NEW_COUNT=$(increment_flip_count)
FLIP_COUNT=$NEW_COUNT
SOMETHING_HAPPENED=true
else
log "On SSD — ramdisk ${RAMDISK_USED_GB}GB still above low threshold ${RAMDISK_LOW_GB}GB"
fi
fi
;;
esac
END=$(date +%s)
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown")
# ==============================================================================================
# ━━━ Daily Log Write ━━━
# ==============================================================================================
# Read by weekly_health_digest.sh — format: DATE|USED_GB|FLIPS|RAM_SESSIONS|SSD_SESSIONS
if [[ "$DRY_RUN" == false && "$NO_LOG" == false && -n "${TRANSCODE_DAILY_LOG:-}" ]]; then
TODAY=$(date '+%Y-%m-%d')
mkdir -p "$(dirname "$TRANSCODE_DAILY_LOG")"
echo "${TODAY}|${RAMDISK_USED_GB}|${FLIP_COUNT}|${RAM_SESSION_COUNT}|${SSD_SESSION_COUNT}" \
>> "$TRANSCODE_DAILY_LOG" 2>/dev/null || true
log "Daily log written: $TRANSCODE_DAILY_LOG"
fi
# ==============================================================================================
# ━━━ Summary — only shown when something happened ━━━
# ==============================================================================================
if [[ "$SOMETHING_HAPPENED" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY TRANSCODE MANAGER SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $TRANSCODE_MANAGER_MODE"
echo "$ICON_RAM Ramdisk: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB available"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK → $CURRENT_TARGET"
echo "$ICON_LINK Flips: $FLIP_COUNT this hour (warn at $TRANSCODE_FLIP_WARN)"
echo "$ICON_EMBY Streams: $TOTAL_SESSIONS total | Live TV: $LIVE_TV | Transcoding: $TRANSCODING | Direct: $DIRECT"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
else
echo "Transcode manager — all healthy — $MY_ID ($(format_duration $(( END - START ))))"
fi
@@ -0,0 +1,321 @@
#!/bin/bash
# ==============================================================================================
# ================================= Transcode Cleanup ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Removes stale transcode files from both ramdisk and SSD fallback locations.
# Called by transcode_management.sh (Orchestrators/) before transcode_manager.sh —
# cleanup must run first so the manager sees real active-session usage, not
# inflated usage from stale files. Must be fast and non-blocking.
#
# A file is eligible for deletion only if ALL conditions are true:
# 1. Older than TRANSCODE_MAX_AGE minutes (mtime — last write time)
# 2. Not currently open by any process (checked via lsof pre-built map)
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# lsof Called Once, Not Per File
# On a busy Live TV system the ramdisk contains thousands of HLS segment files.
# Calling lsof once per file creates thousands of subprocess calls every 7 minutes.
# lsof is called once per location to build a complete open-file map. All subsequent
# checks are O(1) lookups against that map — thousands of files, one lsof call.
#
# No Session-Aware Cleanup
# ffmpeg generates folder names independently of the media server API session IDs.
# There is no reliable correlation between API session IDs and transcoding-temp
# subfolder names. Attempting to correlate them would falsely treat active sessions
# as ended. lsof is the correct check — if ffmpeg has a file open, it is active
# regardless of folder naming or session state.
#
# transcoding-temp Is Never Deleted
# If cleanup removes the empty transcoding-temp folder from the ramdisk, Emby
# searches all accessible paths for an existing one, finds the SSD fallback version,
# and routes all new sessions there until Emby restarts. The directory is excluded
# from find by name — protected even when completely empty.
#
# Post-Cleanup Flip-Back
# After removing stale files, checks whether ramdisk usage dropped below
# RAMDISK_LOW_GB. If so — and symlink currently points at SSD — triggers a
# flip back to ramdisk. This is the recovery path; the manager handles
# the fill-up path.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Wait Lock
# acquire_lock "wait" — waits if a previous cleanup run is still active rather
# than exiting. The caller's 7-minute interval can overlap on a slow system.
#
# lsof Timeout
# lsof call capped at 15 seconds per location — prevents blocking indefinitely
# on a system with many open files.
#
# transcoding-temp Guard
# `! -name "transcoding-temp"` in the find command — protected unconditionally.
#
# Silent by Default
# Runs every 7 minutes — must not produce noise when healthy.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RAMDISK_PATH / HOST*_TRANSCODE_SSD / HOST*_RAMDISK_LOW_GB
# Aliased by detect_hosts() → RAMDISK_PATH / TRANSCODE_SSD / RAMDISK_LOW_GB.
#
# master.conf
#
# TRANSCODE_MAX_AGE
# Minutes before an inactive transcode file is eligible for deletion. (default: 20)
#
# TRANSCODE_ORPHAN_AGE
# Minutes for orphan folder detection. (default: 30)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# transcode_cleanup.sh
# Remove stale files from ramdisk and SSD. Check for flip-back opportunity.
#
# transcode_cleanup.sh --dry-run
# Show which files would be deleted. No deletions, no flip.
#
# transcode_cleanup.sh --status
# Show current file counts, ages, and open-file status per location.
#
# transcode_cleanup.sh --log
# Verbose per-file output including age, open status, and deletion result.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases RAMDISK_PATH, TRANSCODE_SSD, RAMDISK_LOW_GB
detect_hosts
# lsof availability check
LSOF_AVAILABLE=false
if command -v lsof >/dev/null 2>&1; then
LSOF_AVAILABLE=true
log "lsof available — active file check enabled"
else
warn "lsof not available — active file check skipped, all aged files eligible for deletion"
fi
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "$ICON_GEAR Config: max-age=${TRANSCODE_MAX_AGE}min orphan-age=${TRANSCODE_ORPHAN_AGE}min flip-back-below=${RAMDISK_LOW_GB}GB"
log "$ICON_RAM Locations: ramdisk=$RAMDISK_PATH ssd=$TRANSCODE_SSD"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH"
echo "$ICON_DISK SSD fallback: $TRANSCODE_SSD"
echo "$ICON_TRASH Max age: ${TRANSCODE_MAX_AGE} minutes"
echo "$ICON_TRASH Orphan age: ${TRANSCODE_ORPHAN_AGE} minutes"
echo "$ICON_RAM Flip at: ${RAMDISK_LOW_GB}GB"
echo "$ICON_GEAR lsof check: $LSOF_AVAILABLE"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
else
echo " $ICON_RAM Ramdisk: not mounted"
fi
CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
echo " $ICON_LINK Current target: ${CURRENT_TARGET:-unknown}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
# ==============================================================================================
# ── CLEANUP FUNCTION ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Scans a location and removes eligible files.
# Calls lsof ONCE per location — builds in-memory OPEN_FILES_MAP for O(1) lookup.
# Returns via LOCATION_REMOVED, LOCATION_FREED, LOCATION_SKIPPED, LOCATION_ACTIVE
cleanup_location() {
local location="$1" label="$2" max_age="$3"
local files_removed=0 bytes_freed=0 files_skipped=0 files_active=0 files_streaming=0
if [[ ! -d "$location" ]]; then
log "$label does not exist — skipping"
LOCATION_REMOVED=0 LOCATION_FREED="0B" LOCATION_SKIPPED=0 LOCATION_ACTIVE=0 LOCATION_STREAMING=0
return
fi
local file_count
file_count=$(find "$location" -type f 2>/dev/null | wc -l)
log "$label: $file_count files to scan (age threshold: ${max_age}min)"
# Build in-memory open file map — O(1) lookup per file
# One lsof call per location — never per file
declare -A OPEN_FILES_MAP
if [[ "$LSOF_AVAILABLE" == true ]]; then
log "Building open file map for $label..."
while IFS= read -r open_file; do
[[ -n "$open_file" ]] && OPEN_FILES_MAP["$open_file"]=1
done < <(timeout 15 lsof +D "$location" 2>/dev/null | awk 'NR>1 {print $9}' | sort -u)
files_streaming=${#OPEN_FILES_MAP[@]}
log "$files_streaming files currently open in $label"
fi
# Process aged files
while IFS= read -r file; do
[[ -z "$file" ]] && continue
# O(1) open file check — in-memory map
if [[ -n "${OPEN_FILES_MAP[$file]:-}" ]]; then
(( files_active++ ))
log "Skipping open file: $file"
continue
fi
local file_size
file_size=$(stat -c%s "$file" 2>/dev/null || echo 0)
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN — would delete: $(basename "$file")"
(( files_skipped++ ))
else
if rm -f "$file" 2>/dev/null; then
(( files_removed++ ))
bytes_freed=$(( bytes_freed + file_size ))
log "Deleted: $file"
else
warn "Could not delete: $file"
(( files_skipped++ ))
fi
fi
done < <(find "$location" -type f -mmin +"$max_age" 2>/dev/null)
# Remove empty directories — but NEVER remove transcoding-temp
# transcoding-temp must always exist on ramdisk so Emby finds it there first
if [[ "$DRY_RUN" == false ]]; then
find "$location" -mindepth 1 -type d -empty \
! -name "transcoding-temp" -delete 2>/dev/null
fi
# Format bytes freed
local freed_human
if (( bytes_freed > 1073741824 )); then
freed_human=$(awk "BEGIN {printf \"%.1fGB\", $bytes_freed / 1073741824}")
elif (( bytes_freed > 1048576 )); then
freed_human=$(awk "BEGIN {printf \"%.1fMB\", $bytes_freed / 1048576}")
elif (( bytes_freed > 0 )); then
freed_human="${bytes_freed}B"
else
freed_human="0B"
fi
log "$label — removed $files_removed files ($freed_human freed) | streaming: $files_streaming | aged+open: $files_active | skipped: $files_skipped"
LOCATION_REMOVED=$files_removed
LOCATION_FREED=$freed_human
LOCATION_SKIPPED=$files_skipped
LOCATION_ACTIVE=$files_active
LOCATION_STREAMING=$files_streaming
}
# ==============================================================================================
# ━━━ Transcode Cleanup ━━━
# ==============================================================================================
START=$(date +%s)
TOTAL_REMOVED=0 TOTAL_SKIPPED=0 TOTAL_ACTIVE=0 TOTAL_STREAMING=0
RAMDISK_FREED="0B" SSD_FREED="0B"
# Cleanup ramdisk
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
cleanup_location "$RAMDISK_PATH" "Ramdisk" "$TRANSCODE_MAX_AGE"
TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED ))
TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED ))
TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE ))
TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING ))
RAMDISK_FREED=$LOCATION_FREED
else
log "Ramdisk not mounted — skipping ramdisk cleanup"
fi
# Cleanup SSD fallback
if [[ -d "$TRANSCODE_SSD" ]]; then
cleanup_location "$TRANSCODE_SSD" "SSD fallback" "$TRANSCODE_MAX_AGE"
TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED ))
TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED ))
TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE ))
TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING ))
SSD_FREED=$LOCATION_FREED
else
log "SSD fallback not found — skipping SSD cleanup"
fi
# Post-cleanup — check if ramdisk recovered enough to flip symlink back to ramdisk
if [[ "$DRY_RUN" == false ]] && mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}")
LOW_RECOVERED=$(awk "BEGIN {print ($RAMDISK_USED_GB < $RAMDISK_LOW_GB) ? 1 : 0}")
CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$LOW_RECOVERED" == "1" && "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then
echo "Ramdisk has space after cleanup (${RAMDISK_USED_GB}GB < ${RAMDISK_LOW_GB}GB) — triggering manager to flip back"
bash "$SCRIPT_DIR/transcode_manager.sh" --no-log
fi
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY TRANSCODE CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk freed: $RAMDISK_FREED"
echo "$ICON_DISK SSD freed: $SSD_FREED"
echo "$ICON_TRASH Removed: $TOTAL_REMOVED files"
echo "$ICON_RUNNING Streaming: $TOTAL_STREAMING files (open by Emby — untouched)"
echo "$ICON_SHIELD Protected: $TOTAL_ACTIVE aged files saved by open-file check"
echo "$ICON_TRASH Skipped: $TOTAL_SKIPPED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
else
echo "$ICON_DONE Status: done ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,329 @@
#!/bin/bash
# ==============================================================================================
# ================================= Transcode Cleanup ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Removes stale transcode files from both ramdisk and SSD fallback locations.
# Called by transcode_management.sh (Orchestrators/) before transcode_manager.sh —
# cleanup must run first so the manager sees real active-session usage, not
# inflated usage from stale files. Must be fast and non-blocking.
#
# A file is eligible for deletion only if ALL conditions are true:
# 1. Older than TRANSCODE_MAX_AGE minutes (mtime — last write time)
# 2. Not currently open by any process (checked via lsof pre-built map)
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# lsof Called Once, Not Per File
# On a busy Live TV system the ramdisk contains thousands of HLS segment files.
# Calling lsof once per file creates thousands of subprocess calls every 7 minutes.
# lsof is called once per location to build a complete open-file map. All subsequent
# checks are O(1) lookups against that map — thousands of files, one lsof call.
#
# No Session-Aware Cleanup
# ffmpeg generates folder names independently of the media server API session IDs.
# There is no reliable correlation between API session IDs and transcoding-temp
# subfolder names. Attempting to correlate them would falsely treat active sessions
# as ended. lsof is the correct check — if ffmpeg has a file open, it is active
# regardless of folder naming or session state.
#
# transcoding-temp Is Never Deleted
# If cleanup removes the empty transcoding-temp folder from the ramdisk, Emby
# searches all accessible paths for an existing one, finds the SSD fallback version,
# and routes all new sessions there until Emby restarts. The directory is excluded
# from find by name — protected even when completely empty.
#
# Post-Cleanup Flip-Back
# After removing stale files, checks whether ramdisk usage dropped below
# RAMDISK_LOW_GB. If so — and symlink currently points at SSD — triggers a
# flip back to ramdisk. This is the recovery path; the manager handles
# the fill-up path.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Wait Lock
# acquire_lock "wait" — waits if a previous cleanup run is still active rather
# than exiting. The caller's 7-minute interval can overlap on a slow system.
#
# lsof Timeout
# lsof call capped at 15 seconds per location — prevents blocking indefinitely
# on a system with many open files.
#
# transcoding-temp Guard
# `! -name "transcoding-temp"` in the find command — protected unconditionally.
#
# Silent by Default
# Runs every 7 minutes — must not produce noise when healthy.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RAMDISK_PATH / HOST*_TRANSCODE_SSD / HOST*_RAMDISK_LOW_GB
# Aliased by detect_hosts() → RAMDISK_PATH / TRANSCODE_SSD / RAMDISK_LOW_GB.
#
# master.conf
#
# TRANSCODE_MAX_AGE
# Minutes before an inactive transcode file is eligible for deletion. (default: 20)
#
# TRANSCODE_ORPHAN_AGE
# Minutes for orphan folder detection. (default: 30)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# transcode_cleanup.sh
# Remove stale files from ramdisk and SSD. Check for flip-back opportunity.
#
# transcode_cleanup.sh --dry-run
# Show which files would be deleted. No deletions, no flip.
#
# transcode_cleanup.sh --status
# Show current file counts, ages, and open-file status per location.
#
# transcode_cleanup.sh --log
# Verbose per-file output including age, open status, and deletion result.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases RAMDISK_PATH, TRANSCODE_SSD, RAMDISK_LOW_GB
detect_hosts
# lsof availability check
LSOF_AVAILABLE=false
if command -v lsof >/dev/null 2>&1; then
LSOF_AVAILABLE=true
log "lsof available — active file check enabled"
else
warn "lsof not available — active file check skipped, all aged files eligible for deletion"
fi
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "$ICON_GEAR Config: max-age=${TRANSCODE_MAX_AGE}min orphan-age=${TRANSCODE_ORPHAN_AGE}min flip-back-below=${RAMDISK_LOW_GB}GB"
log "$ICON_RAM Locations: ramdisk=$RAMDISK_PATH ssd=$TRANSCODE_SSD"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH"
echo "$ICON_DISK SSD fallback: $TRANSCODE_SSD"
echo "$ICON_TRASH Max age: ${TRANSCODE_MAX_AGE} minutes"
echo "$ICON_TRASH Orphan age: ${TRANSCODE_ORPHAN_AGE} minutes"
echo "$ICON_RAM Flip at: ${RAMDISK_LOW_GB}GB"
echo "$ICON_GEAR lsof check: $LSOF_AVAILABLE"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
else
echo " $ICON_RAM Ramdisk: not mounted"
fi
CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
echo " $ICON_LINK Current target: ${CURRENT_TARGET:-unknown}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
# ==============================================================================================
# ── CLEANUP FUNCTION ──────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Scans a location and removes eligible files.
# Calls lsof ONCE per location — builds in-memory OPEN_FILES_MAP for O(1) lookup.
# Returns via LOCATION_REMOVED, LOCATION_FREED, LOCATION_SKIPPED, LOCATION_ACTIVE
cleanup_location() {
local location="$1" label="$2" max_age="$3"
local files_removed=0 bytes_freed=0 files_skipped=0 files_active=0 files_streaming=0 files_too_young=0
if [[ ! -d "$location" ]]; then
log "$label does not exist — skipping"
LOCATION_REMOVED=0 LOCATION_FREED="0B" LOCATION_SKIPPED=0 LOCATION_ACTIVE=0 LOCATION_STREAMING=0 LOCATION_TOO_YOUNG=0
return
fi
local file_count eligible_count
file_count=$(find "$location" -type f 2>/dev/null | wc -l)
eligible_count=$(find "$location" -type f -mmin +"$max_age" 2>/dev/null | wc -l)
files_too_young=$(( file_count - eligible_count ))
log "$label: $file_count files ($eligible_count eligible, $files_too_young < ${max_age}min)"
# Build in-memory open file map — O(1) lookup per file
# One lsof call per location — never per file
# Note: HLS/remux segments (Live TV, Direct Stream) are written atomically and immediately
# closed — lsof will not detect them. The age check is the effective guard for those files.
declare -A OPEN_FILES_MAP
if [[ "$LSOF_AVAILABLE" == true ]]; then
log "Building open file map for $label..."
while IFS= read -r open_file; do
[[ -n "$open_file" ]] && OPEN_FILES_MAP["$open_file"]=1
done < <(timeout 15 lsof +D "$location" 2>/dev/null | awk 'NR>1 {print $9}' | sort -u)
files_streaming=${#OPEN_FILES_MAP[@]}
log "$files_streaming files currently open in $label"
fi
# Process aged files
while IFS= read -r file; do
[[ -z "$file" ]] && continue
# O(1) open file check — in-memory map
if [[ -n "${OPEN_FILES_MAP[$file]:-}" ]]; then
(( files_active++ ))
log "Skipping open file: $file"
continue
fi
local file_size
file_size=$(stat -c%s "$file" 2>/dev/null || echo 0)
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN — would delete: $(basename "$file")"
(( files_skipped++ ))
else
if rm -f "$file" 2>/dev/null; then
(( files_removed++ ))
bytes_freed=$(( bytes_freed + file_size ))
log "Deleted: $file"
else
warn "Could not delete: $file"
(( files_skipped++ ))
fi
fi
done < <(find "$location" -type f -mmin +"$max_age" 2>/dev/null)
# Remove empty directories — but NEVER remove transcoding-temp
# transcoding-temp must always exist on ramdisk so Emby finds it there first
if [[ "$DRY_RUN" == false ]]; then
find "$location" -mindepth 1 -type d -empty \
! -name "transcoding-temp" -delete 2>/dev/null
fi
# Format bytes freed
local freed_human
if (( bytes_freed > 1073741824 )); then
freed_human=$(awk "BEGIN {printf \"%.1fGB\", $bytes_freed / 1073741824}")
elif (( bytes_freed > 1048576 )); then
freed_human=$(awk "BEGIN {printf \"%.1fMB\", $bytes_freed / 1048576}")
elif (( bytes_freed > 0 )); then
freed_human="${bytes_freed}B"
else
freed_human="0B"
fi
log "$label — removed $files_removed ($freed_human) | active(fresh): $files_too_young | streaming(lsof): $files_streaming | protected(old+open): $files_active | skipped: $files_skipped"
LOCATION_REMOVED=$files_removed
LOCATION_FREED=$freed_human
LOCATION_SKIPPED=$files_skipped
LOCATION_ACTIVE=$files_active
LOCATION_STREAMING=$files_streaming
LOCATION_TOO_YOUNG=$files_too_young
}
# ==============================================================================================
# ━━━ Transcode Cleanup ━━━
# ==============================================================================================
START=$(date +%s)
TOTAL_REMOVED=0 TOTAL_SKIPPED=0 TOTAL_ACTIVE=0 TOTAL_STREAMING=0 TOTAL_TOO_YOUNG=0
RAMDISK_FREED="0B" SSD_FREED="0B"
# Cleanup ramdisk
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
cleanup_location "$RAMDISK_PATH" "Ramdisk" "$TRANSCODE_MAX_AGE"
TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED ))
TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED ))
TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE ))
TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING ))
TOTAL_TOO_YOUNG=$(( TOTAL_TOO_YOUNG + LOCATION_TOO_YOUNG ))
RAMDISK_FREED=$LOCATION_FREED
else
log "Ramdisk not mounted — skipping ramdisk cleanup"
fi
# Cleanup SSD fallback
if [[ -d "$TRANSCODE_SSD" ]]; then
cleanup_location "$TRANSCODE_SSD" "SSD fallback" "$TRANSCODE_MAX_AGE"
TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED ))
TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED ))
TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE ))
TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING ))
TOTAL_TOO_YOUNG=$(( TOTAL_TOO_YOUNG + LOCATION_TOO_YOUNG ))
SSD_FREED=$LOCATION_FREED
else
log "SSD fallback not found — skipping SSD cleanup"
fi
# Post-cleanup — check if ramdisk recovered enough to flip symlink back to ramdisk
if [[ "$DRY_RUN" == false ]] && mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}")
LOW_RECOVERED=$(awk "BEGIN {print ($RAMDISK_USED_GB < $RAMDISK_LOW_GB) ? 1 : 0}")
CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$LOW_RECOVERED" == "1" && "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then
echo "Ramdisk has space after cleanup (${RAMDISK_USED_GB}GB < ${RAMDISK_LOW_GB}GB) — triggering manager to flip back"
bash "$SCRIPT_DIR/transcode_manager.sh" --no-log
fi
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY TRANSCODE CLEANUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk freed: $RAMDISK_FREED"
echo "$ICON_DISK SSD freed: $SSD_FREED"
echo "$ICON_TRASH Removed: $TOTAL_REMOVED files"
echo "$ICON_RUNNING Active: $TOTAL_TOO_YOUNG files (< ${TRANSCODE_MAX_AGE}min old — Live TV / Direct Stream segments)"
echo "$ICON_RUNNING Streaming: $TOTAL_STREAMING files (open file handle — long-running transcode)"
echo "$ICON_SHIELD Protected: $TOTAL_ACTIVE aged files saved by open-file check"
echo "$ICON_TRASH Skipped: $TOTAL_SKIPPED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no files deleted"
else
echo "$ICON_DONE Status: done ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,650 @@
#!/bin/bash
# ==============================================================================================
# ================================= Downloaders Reset ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintenance reset for all download clients on this server. Clears accumulated
# state that download clients generate but never clean up themselves — stuck
# searches, dead transfers, failed imports, stale queue entries, completed history.
#
# Called every 30 minutes by critical_sync_maintenance.sh via
# CRITICAL_MAINTENANCE_SCRIPTS. Can also be run manually for ad hoc cleanup.
# If a downloader is not configured for this host, that section skips cleanly.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# slskd
# Stuck searches — clears Completed/Errored searches left by Soularr crashes
# prevents 409 Conflict on next Soularr startup
# Dead transfers — removes completed/errored/aborted transfer records per user
# prevents Soularr 404 loop when polling a user whose transfer is gone
# NEVER removes InProgress or Queued transfers
# Failed imports — purges albums Soularr downloaded but Lidarr rejected
# Soularr moves these to failed_imports/ and never cleans them up
#
# SABnzbd
# Completed history — removes completed download records older than DOWNLOADER_RETENTION_DAYS
# Failed history — removes failed download records older than DOWNLOADER_RETENTION_DAYS
# Stalled queue — removes Paused or Stuck queue items no longer progressing
# active downloading items are never touched
#
# qBittorrent
# Age failsafe — removes torrents older than QBIT_FAILSAFE_MIN_DAYS
# deleteFiles=false — removes from qBit, leaves files for arrs to manage
# optional ratio requirement via QBIT_FAILSAFE_MIN_RATIO
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Never Interrupt Active Downloads
# Each downloader section checks for active state before any removal. slskd
# skips users with InProgress or Queued transfers. SABnzbd only removes items
# past the retention threshold. qBittorrent applies minimum age and optional
# ratio requirements. In-progress work is never touched.
#
# Graceful Skip on Unavailability
# If a downloader's URL is empty or the service is unreachable, that section
# skips cleanly with a log message. The script never exits fatally on a single
# unreachable downloader — the others still run.
#
# Host-Aware Configuration
# detect_hosts() aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars
# to their unprefixed names. Downloaders not configured for this host are absent
# from the aliased vars and skip automatically.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Active Transfer Protection
# slskd: skips users with InProgress or Queued transfers before any removal.
# SABnzbd: age threshold enforced before deletion.
# qBittorrent: minimum age plus optional ratio gate before failsafe removal.
#
# Reachability Check
# Each section validates its downloader URL before API calls. Missing or
# unreachable downloaders skip without affecting other sections.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# all HOST*_SLSKD_*, HOST*_SABNZBD_*, and HOST*_QBIT_* vars to the correct
# host's values. Downloaders not configured on this host skip automatically.
#
# Lock Acquisition
# acquire_lock "wait" — waits for previous run to finish since this runs every
# 30 minutes and prior execution may still be completing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_SLSKD_URL / HOST*_SLSKD_API_KEY / HOST*_SLSKD_FAILED_IMPORTS_DIR
# slskd connection and failed imports path. Aliased by detect_hosts()
#
# HOST*_SABNZBD_URL / HOST*_SABNZBD_API_KEY
# SABnzbd connection details. Aliased by detect_hosts()
#
# HOST*_QBIT_URL / HOST*_QBIT_USERNAME / HOST*_QBIT_PASSWORD
# qBittorrent connection details. Aliased by detect_hosts()
#
# master.conf
#
# DOWNLOADER_RETENTION_DAYS
# Days before SABnzbd history entries (completed or failed) are removed
#
# QBIT_FAILSAFE_MIN_DAYS
# Minimum torrent age in days before failsafe removal is considered
#
# QBIT_FAILSAFE_MIN_RATIO
# Minimum seeding ratio required alongside age gate (0 = age only)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# downloaders_reset.sh
# Run maintenance reset for all configured download clients
#
# downloaders_reset.sh --dry-run
# Preview what would be removed without making any changes
#
# downloaders_reset.sh --status
# Show configured downloaders, current queue depths, and retention settings
#
# downloaders_reset.sh --log
# Verbose per-client per-item output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Lock first — wait mode since this runs every 30min and previous may still be finishing
acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars
detect_hosts
START_TIME=$(date +%s)
CUTOFF=$(( $(date +%s) - (DOWNLOADER_RETENTION_DAYS * 86400) ))
TOTAL_PASS=0
TOTAL_FAIL=0
log "$ICON_GEAR Config: retention=${DOWNLOADER_RETENTION_DAYS}d qbit-age=${QBIT_FAILSAFE_MIN_DAYS}d qbit-ratio=${QBIT_FAILSAFE_MIN_RATIO}"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR slskd: ${SLSKD_URL:-not configured}"
echo "$ICON_GEAR SABnzbd: ${SABNZBD_URL:-not configured}"
echo "$ICON_GEAR qBittorrent: ${QBIT_URL:-not configured}"
echo "$ICON_TIME Retention: ${DOWNLOADER_RETENTION_DAYS} days"
echo "$ICON_GEAR qBit age: ${QBIT_FAILSAFE_MIN_DAYS} days"
echo "$ICON_GEAR qBit ratio: ${QBIT_FAILSAFE_MIN_RATIO} (0=age only)"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# Log which downloaders are active on this host
if [[ -z "$SLSKD_URL" ]] && [[ -z "$SABNZBD_URL" ]] && [[ -z "$QBIT_URL" ]]; then
warn "No downloaders configured for $MY_ID — nothing to reset"
exit 0
fi
[[ -n "$SLSKD_URL" ]] && log "slskd active on $MY_ID"
[[ -n "$SABNZBD_URL" ]] && log "SABnzbd active on $MY_ID"
[[ -n "$QBIT_URL" ]] && log "qBittorrent active on $MY_ID"
# ==============================================================================================
# ━━━ slskd — Connection Check ━━━
# ==============================================================================================
# slskd's internal watchdog doesn't always recover from disconnection. Check before
# running API-dependent sections; attempt reconnect if down.
SLSKD_CONNECTED=false
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]]; then
echo ""
echo "━━━ $ICON_SYNC slskd — Connection Check ━━━"
_slskd_is_connected() {
local state
state=$(curl -sf --max-time 10 \
-H "X-Api-Key: $SLSKD_API_KEY" \
"$SLSKD_URL/api/v0/application" 2>/dev/null | \
jq -r '.server.isConnected // false' 2>/dev/null)
[[ "$state" == "true" ]]
}
if _slskd_is_connected; then
log "slskd connected to Soulseek ✅"
SLSKD_CONNECTED=true
else
warn "slskd disconnected — triggering reconnect"
curl -sf --max-time 10 -X PUT \
-H "X-Api-Key: $SLSKD_API_KEY" \
-H "Content-Type: application/json" \
"$SLSKD_URL/api/v0/server" \
-d '{"address":"server.slsknet.org","port":2242}' \
>/dev/null 2>&1
_ELAPSED=0
while [[ "$_ELAPSED" -lt 60 ]]; do
sleep 10
_ELAPSED=$(( _ELAPSED + 10 ))
if _slskd_is_connected; then
log "slskd reconnected after ${_ELAPSED}s ✅"
SLSKD_CONNECTED=true
break
fi
log " waiting... (${_ELAPSED}s / 60s)"
done
[[ "$SLSKD_CONNECTED" != true ]] && \
warn "slskd still disconnected after 60s — skipping API-dependent sections"
fi
fi
# ==============================================================================================
# ━━━ slskd — Stuck Searches ━━━
# ==============================================================================================
# Clears searches in Completed/Errored state left by Soularr crashes.
# Prevents 409 Conflict error on next Soularr startup when it tries to
# create a search with the same ID that already exists in a terminal state.
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then
echo ""
echo "━━━ 🔍 slskd — Stuck Searches ━━━"
SEARCHES=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/searches" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
if [[ -z "$SEARCHES" ]]; then
warn "slskd not reachable — skipping searches"
else
IDS=$(echo "$SEARCHES" | tr '{' '\n' | \
grep '"isComplete":true' | grep '"searchText":' | \
grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//')
COUNT=$(echo "$IDS" | grep -c . 2>/dev/null || echo 0)
COUNT="${COUNT//[^0-9]/}"; COUNT="${COUNT:-0}"
if [[ "$COUNT" -eq 0 ]]; then
success "No stuck searches found ✅"
else
log "Found $COUNT stuck search(es)"
SUCCESS=0; FAIL=0
while IFS= read -r ID; do
[[ -z "$ID" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete search: $ID"
((SUCCESS++))
continue
fi
RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \
"$SLSKD_URL/api/v0/searches/$ID" \
-H "X-Api-Key: $SLSKD_API_KEY")
if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then
log "$ICON_TRASH Cleared search: $ID"
((SUCCESS++))
else
error "Failed: $ID (HTTP $RESULT)"
((FAIL++))
fi
done <<< "$IDS"
success "Searches: $SUCCESS cleared, $FAIL failed"
(( TOTAL_FAIL += FAIL ))
(( TOTAL_PASS += SUCCESS ))
fi
fi
fi
# ==============================================================================================
# ━━━ slskd — Dead Transfer Records ━━━
# ==============================================================================================
# Removes completed/errored/aborted transfer records per user.
# Prevents Soularr 404 loop when polling a user whose transfer no longer exists.
# Safety: NEVER removes transfers that are InProgress or Queued — active downloads protected.
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then
echo ""
echo "━━━ 🔍 slskd — Dead Transfer Records ━━━"
TRANSFERS=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/transfers/downloads" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
if [[ -z "$TRANSFERS" ]]; then
warn "slskd not reachable — skipping transfers"
else
USERNAMES=$(echo "$TRANSFERS" | grep -o '"username":"[^"]*"' | \
sed 's/"username":"//;s/"//' | sort -u)
if [[ -z "$USERNAMES" ]]; then
success "No transfer records found ✅"
else
USER_COUNT=$(echo "$USERNAMES" | grep -c . 2>/dev/null || echo 0)
log "Found $USER_COUNT user(s) with transfer records"
SUCCESS=0; SKIPPED=0; FAIL=0
while IFS= read -r USER; do
[[ -z "$USER" ]] && continue
USER_DATA=$(curl -sf --max-time 10 \
"$SLSKD_URL/api/v0/transfers/downloads/$USER" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
# Skip users with any active or queued transfers — never interrupt downloads
ACTIVE=$(echo "$USER_DATA" | grep -c '"state":"InProgress"\|"state":"Queued"')
if [[ "${ACTIVE:-0}" -gt 0 ]]; then
log "$ICON_SKIP Skipping $USER — has active/queued transfer(s)"
((SKIPPED++))
continue
fi
# Extract IDs of terminal-state file transfers
# Split at { so each file object lands on its own line, then grep for state
FILE_IDS=$(echo "$USER_DATA" | tr '{' '\n' | \
grep '"state":"Completed"\|"state":"Errored"\|"state":"Aborted"\|"state":"Cancelled"' | \
grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//')
if [[ -z "$FILE_IDS" ]]; then
log "$ICON_SKIP Skipping $USER — no terminal-state transfers"
((SKIPPED++))
continue
fi
if [[ "$DRY_RUN" == true ]]; then
F_COUNT=$(echo "$FILE_IDS" | grep -c .)
warn "DRY RUN — would clear $F_COUNT transfer(s) for: $USER"
((SUCCESS++))
continue
fi
F_SUCCESS=0; F_FAIL=0
while IFS= read -r FILE_ID; do
[[ -z "$FILE_ID" ]] && continue
RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \
"$SLSKD_URL/api/v0/transfers/downloads/$USER/$FILE_ID" \
-H "X-Api-Key: $SLSKD_API_KEY")
if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then
((F_SUCCESS++))
else
((F_FAIL++))
fi
done <<< "$FILE_IDS"
log "$ICON_TRASH Cleared $F_SUCCESS transfer(s) for: $USER ($F_FAIL failed)"
((SUCCESS += F_SUCCESS))
((FAIL += F_FAIL))
done <<< "$USERNAMES"
success "Transfers: $SUCCESS cleared, $SKIPPED skipped (active/empty), $FAIL failed"
(( TOTAL_FAIL += FAIL ))
(( TOTAL_PASS += SUCCESS ))
fi
fi
fi
# ==============================================================================================
# ━━━ slskd — Purge Expired Failed Imports ━━━
# ==============================================================================================
# Removes albums Soularr downloaded but Lidarr rejected.
# Soularr moves rejected albums to failed_imports/ and never cleans them up.
# Purges directories older than DOWNLOADER_RETENTION_DAYS to prevent unbounded growth.
if [[ -n "$SLSKD_FAILED_IMPORTS_DIR" ]]; then
echo ""
echo "━━━ 🔍 slskd — Failed Imports (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
if [[ ! -d "$SLSKD_FAILED_IMPORTS_DIR" ]]; then
warn "Directory not found: $SLSKD_FAILED_IMPORTS_DIR — skipping"
else
OLD_IMPORTS=$(find "$SLSKD_FAILED_IMPORTS_DIR" \
-mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}")
IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || echo 0)
IMPORT_COUNT="${IMPORT_COUNT//[^0-9]/}"; IMPORT_COUNT="${IMPORT_COUNT:-0}"
if [[ "$IMPORT_COUNT" -eq 0 ]]; then
success "No expired failed imports found ✅"
else
log "Found $IMPORT_COUNT expired failed import(s)"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete:"
echo "$OLD_IMPORTS"
else
find "$SLSKD_FAILED_IMPORTS_DIR" \
-mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}" \
-exec rm -rf {} \;
success "$ICON_TRASH Purged $IMPORT_COUNT expired failed import(s)"
(( TOTAL_PASS += IMPORT_COUNT ))
fi
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Clear Completed History ━━━
# ==============================================================================================
# Removes completed download history older than DOWNLOADER_RETENTION_DAYS.
# Keeps recent history for reference — only purges what's past the retention window.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Completed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
HISTORY=$(curl -sf --max-time 15 \
"$SABNZBD_URL/api?mode=history&output=json&limit=1000&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$HISTORY" ]]; then
warn "SABnzbd not reachable — skipping completed history"
else
COMPLETED_IDS=$(echo "$HISTORY" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$COMPLETED_IDS" ]]; then
success "No completed history found ✅"
else
HIST_TOTAL=$(echo "$COMPLETED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $HIST_TOTAL completed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
JOB_TIME=$(echo "$HISTORY" | grep -A5 "$NZO_ID" | \
grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1)
[[ -z "$JOB_TIME" ]] && continue
[[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete completed job: $NZO_ID"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Deleted: $NZO_ID"
((DELETED++))
fi
done <<< "$COMPLETED_IDS"
success "Completed: $DELETED deleted, $SKIPPED within retention"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Clear Failed History ━━━
# ==============================================================================================
# Removes failed download history older than DOWNLOADER_RETENTION_DAYS.
# Failed history is kept briefly for diagnosis but purged after the retention window.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Failed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
FAILED_HIST=$(curl -sf --max-time 15 \
"$SABNZBD_URL/api?mode=history&output=json&limit=1000&failed_only=1&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$FAILED_HIST" ]]; then
warn "SABnzbd not reachable — skipping failed history"
else
FAILED_IDS=$(echo "$FAILED_HIST" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$FAILED_IDS" ]]; then
success "No failed history found ✅"
else
FAILED_TOTAL=$(echo "$FAILED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $FAILED_TOTAL failed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
JOB_TIME=$(echo "$FAILED_HIST" | grep -A5 "$NZO_ID" | \
grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1)
[[ -z "$JOB_TIME" ]] && continue
[[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete failed job: $NZO_ID"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Deleted: $NZO_ID"
((DELETED++))
fi
done <<< "$FAILED_IDS"
success "Failed: $DELETED deleted, $SKIPPED within retention"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Remove Stalled Queue Items ━━━
# ==============================================================================================
# Removes queue items in Paused or Stuck state that are no longer progressing.
# Active downloading items (Downloading, Grabbing) are never touched.
# Paused items may be intentional pauses — but in an automated environment
# a Paused item sitting in the queue indefinitely is effectively stalled.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Stalled Queue Items ━━━"
QUEUE=$(curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=queue&output=json&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$QUEUE" ]]; then
warn "SABnzbd not reachable — skipping queue"
else
STALLED_IDS=$(echo "$QUEUE" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$STALLED_IDS" ]]; then
success "No stalled queue items found ✅"
else
QUEUE_TOTAL=$(echo "$STALLED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $QUEUE_TOTAL queue item(s) — checking status"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
STATUS=$(echo "$QUEUE" | grep -A10 "$NZO_ID" | \
grep -o '"status":"[^"]*"' | sed 's/"status":"//;s/"//')
# Only remove Paused or Stuck items — Downloading/Grabbing are active
if [[ "$STATUS" != "Paused" ]] && [[ "$STATUS" != "Stuck" ]]; then
((SKIPPED++))
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove stalled item: $NZO_ID ($STATUS)"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=queue&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Removed stalled ($STATUS): $NZO_ID"
((DELETED++))
fi
done <<< "$STALLED_IDS"
success "Queue: $DELETED removed, $SKIPPED active (skipped)"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ qBittorrent — Age Failsafe Cleanup ━━━
# ==============================================================================================
# Last-chance cleanup for torrents that have been sitting in qBit past their useful life.
# deleteFiles=false — removes the torrent record from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently — this only cleans up the qBit entry.
#
# Safety checks before deletion:
# Age must exceed QBIT_FAILSAFE_MIN_DAYS
# Ratio must meet QBIT_FAILSAFE_MIN_RATIO (0 = age only, no ratio requirement)
if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then
echo ""
echo "━━━ 🔍 qBittorrent — Failsafe (older than ${QBIT_FAILSAFE_MIN_DAYS} days) ━━━"
[[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]] && \
log "Ratio requirement: >= ${QBIT_FAILSAFE_MIN_RATIO}"
QBIT_COOKIE=$(curl -sf --max-time 10 -c - \
"$QBIT_URL/api/v2/auth/login" \
--data "username=$QBIT_USERNAME&password=$QBIT_PASSWORD" 2>/dev/null | \
grep SID | awk '{print "SID="$NF}')
if [[ -z "$QBIT_COOKIE" ]]; then
error "Failed to authenticate with qBittorrent — check QBIT_USERNAME/PASSWORD"
notify "qBittorrent auth failed on $(hostname) — check credentials in host*.conf" "Downloaders Reset" "warning"
((TOTAL_FAIL++))
else
TORRENTS=$(curl -sf --max-time 15 \
"$QBIT_URL/api/v2/torrents/info" \
-H "Cookie: $QBIT_COOKIE" 2>/dev/null)
NOW=$(date +%s)
TORRENT_TOTAL=$(echo "$TORRENTS" | tr '}' '\n' | grep -c '"hash"' 2>/dev/null || echo 0)
log "Found $TORRENT_TOTAL torrent(s) — applying age/ratio filter"
DELETED=0; SKIPPED=0
while read -r TORRENT; do
[[ -z "$TORRENT" ]] && continue
HASH=$(echo "$TORRENT" | grep -o '"hash":"[^"]*"' | sed 's/"hash":"//;s/"//')
NAME=$(echo "$TORRENT" | grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//')
ADDED=$(echo "$TORRENT" | grep -o '"added_on":[0-9]*' | grep -o '[0-9]*')
RATIO=$(echo "$TORRENT" | grep -o '"ratio":[0-9.]*' | grep -o '[0-9.]*')
[[ -z "$HASH" || -z "$ADDED" ]] && continue
AGE_DAYS=$(( (NOW - ADDED) / 86400 ))
# Age check — must be old enough
[[ "$AGE_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue
# Ratio check — if configured
if [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]]; then
RATIO_INT="${RATIO%.*}"
MIN_RATIO_INT="${QBIT_FAILSAFE_MIN_RATIO%.*}"
[[ "$RATIO_INT" -lt "$MIN_RATIO_INT" ]] && ((SKIPPED++)) && continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete: $NAME (${AGE_DAYS}d old, ratio: $RATIO)"
((DELETED++))
else
curl -sf --max-time 10 -X POST \
"$QBIT_URL/api/v2/torrents/delete" \
-H "Cookie: $QBIT_COOKIE" \
--data "hashes=$HASH&deleteFiles=false" >/dev/null
log "$ICON_TRASH Deleted: $NAME (${AGE_DAYS}d old, ratio: $RATIO)"
((DELETED++))
fi
done < <(echo "$TORRENTS" | tr '}' '\n')
success "qBittorrent: $DELETED deleted, $SKIPPED skipped (under threshold)"
(( TOTAL_PASS += DELETED ))
fi
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DOWNLOADERS RESET SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( $(date +%s) - START_TIME )))"
echo "$ICON_SUCCESS Actions: $TOTAL_PASS"
echo "$ICON_ERROR Failures: $TOTAL_FAIL"
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -gt 0 ]]; then
echo "$ICON_ERROR Status: $TOTAL_FAIL failure(s) — check logs"
notify "Downloaders reset completed with failures on $(hostname)" "Downloaders Reset" "warning"
exit 1
else
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,650 @@
#!/bin/bash
# ==============================================================================================
# ================================= Downloaders Reset ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintenance reset for all download clients on this server. Clears accumulated
# state that download clients generate but never clean up themselves — stuck
# searches, dead transfers, failed imports, stale queue entries, completed history.
#
# Called every 30 minutes by critical_sync_maintenance.sh via
# CRITICAL_MAINTENANCE_SCRIPTS. Can also be run manually for ad hoc cleanup.
# If a downloader is not configured for this host, that section skips cleanly.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# slskd
# Stuck searches — clears Completed/Errored searches left by Soularr crashes
# prevents 409 Conflict on next Soularr startup
# Dead transfers — removes completed/errored/aborted transfer records per user
# prevents Soularr 404 loop when polling a user whose transfer is gone
# NEVER removes InProgress or Queued transfers
# Failed imports — purges albums Soularr downloaded but Lidarr rejected
# Soularr moves these to failed_imports/ and never cleans them up
#
# SABnzbd
# Completed history — removes completed download records older than DOWNLOADER_RETENTION_DAYS
# Failed history — removes failed download records older than DOWNLOADER_RETENTION_DAYS
# Stalled queue — removes Paused or Stuck queue items no longer progressing
# active downloading items are never touched
#
# qBittorrent
# Age failsafe — removes torrents older than QBIT_FAILSAFE_MIN_DAYS
# deleteFiles=false — removes from qBit, leaves files for arrs to manage
# optional ratio requirement via QBIT_FAILSAFE_MIN_RATIO
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Never Interrupt Active Downloads
# Each downloader section checks for active state before any removal. slskd
# skips users with InProgress or Queued transfers. SABnzbd only removes items
# past the retention threshold. qBittorrent applies minimum age and optional
# ratio requirements. In-progress work is never touched.
#
# Graceful Skip on Unavailability
# If a downloader's URL is empty or the service is unreachable, that section
# skips cleanly with a log message. The script never exits fatally on a single
# unreachable downloader — the others still run.
#
# Host-Aware Configuration
# detect_hosts() aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars
# to their unprefixed names. Downloaders not configured for this host are absent
# from the aliased vars and skip automatically.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Active Transfer Protection
# slskd: skips users with InProgress or Queued transfers before any removal.
# SABnzbd: age threshold enforced before deletion.
# qBittorrent: minimum age plus optional ratio gate before failsafe removal.
#
# Reachability Check
# Each section validates its downloader URL before API calls. Missing or
# unreachable downloaders skip without affecting other sections.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# all HOST*_SLSKD_*, HOST*_SABNZBD_*, and HOST*_QBIT_* vars to the correct
# host's values. Downloaders not configured on this host skip automatically.
#
# Lock Acquisition
# acquire_lock "wait" — waits for previous run to finish since this runs every
# 30 minutes and prior execution may still be completing.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_SLSKD_URL / HOST*_SLSKD_API_KEY / HOST*_SLSKD_FAILED_IMPORTS_DIR
# slskd connection and failed imports path. Aliased by detect_hosts()
#
# HOST*_SABNZBD_URL / HOST*_SABNZBD_API_KEY
# SABnzbd connection details. Aliased by detect_hosts()
#
# HOST*_QBIT_URL / HOST*_QBIT_USERNAME / HOST*_QBIT_PASSWORD
# qBittorrent connection details. Aliased by detect_hosts()
#
# master.conf
#
# DOWNLOADER_RETENTION_DAYS
# Days before SABnzbd history entries (completed or failed) are removed
#
# QBIT_FAILSAFE_MIN_DAYS
# Minimum torrent age in days before failsafe removal is considered
#
# QBIT_FAILSAFE_MIN_RATIO
# Minimum seeding ratio required alongside age gate (0 = age only)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# downloaders_reset.sh
# Run maintenance reset for all configured download clients
#
# downloaders_reset.sh --dry-run
# Preview what would be removed without making any changes
#
# downloaders_reset.sh --status
# Show configured downloaders, current queue depths, and retention settings
#
# downloaders_reset.sh --log
# Verbose per-client per-item output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Lock first — wait mode since this runs every 30min and previous may still be finishing
acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars
detect_hosts
START_TIME=$(date +%s)
CUTOFF=$(( $(date +%s) - (DOWNLOADER_RETENTION_DAYS * 86400) ))
TOTAL_PASS=0
TOTAL_FAIL=0
log "$ICON_GEAR Config: retention=${DOWNLOADER_RETENTION_DAYS}d qbit-age=${QBIT_FAILSAFE_MIN_DAYS}d qbit-ratio=${QBIT_FAILSAFE_MIN_RATIO}"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR slskd: ${SLSKD_URL:-not configured}"
echo "$ICON_GEAR SABnzbd: ${SABNZBD_URL:-not configured}"
echo "$ICON_GEAR qBittorrent: ${QBIT_URL:-not configured}"
echo "$ICON_TIME Retention: ${DOWNLOADER_RETENTION_DAYS} days"
echo "$ICON_GEAR qBit age: ${QBIT_FAILSAFE_MIN_DAYS} days"
echo "$ICON_GEAR qBit ratio: ${QBIT_FAILSAFE_MIN_RATIO} (0=age only)"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# Log which downloaders are active on this host
if [[ -z "$SLSKD_URL" ]] && [[ -z "$SABNZBD_URL" ]] && [[ -z "$QBIT_URL" ]]; then
warn "No downloaders configured for $MY_ID — nothing to reset"
exit 0
fi
[[ -n "$SLSKD_URL" ]] && log "slskd active on $MY_ID"
[[ -n "$SABNZBD_URL" ]] && log "SABnzbd active on $MY_ID"
[[ -n "$QBIT_URL" ]] && log "qBittorrent active on $MY_ID"
# ==============================================================================================
# ━━━ slskd — Connection Check ━━━
# ==============================================================================================
# slskd's internal watchdog doesn't always recover from disconnection. Check before
# running API-dependent sections; attempt reconnect if down.
SLSKD_CONNECTED=false
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]]; then
echo ""
echo "━━━ $ICON_SYNC slskd — Connection Check ━━━"
_slskd_is_connected() {
local state
state=$(curl -sf --max-time 10 \
-H "X-Api-Key: $SLSKD_API_KEY" \
"$SLSKD_URL/api/v0/application" 2>/dev/null | \
jq -r '.server.isConnected // false' 2>/dev/null)
[[ "$state" == "true" ]]
}
if _slskd_is_connected; then
echo "$ICON_DONE [OK] slskd connected to Soulseek ✅"
SLSKD_CONNECTED=true
else
warn "slskd disconnected — triggering reconnect"
curl -sf --max-time 10 -X PUT \
-H "X-Api-Key: $SLSKD_API_KEY" \
-H "Content-Type: application/json" \
"$SLSKD_URL/api/v0/server" \
-d '{"address":"server.slsknet.org","port":2242}' \
>/dev/null 2>&1
_ELAPSED=0
while [[ "$_ELAPSED" -lt 60 ]]; do
sleep 10
_ELAPSED=$(( _ELAPSED + 10 ))
if _slskd_is_connected; then
log "slskd reconnected after ${_ELAPSED}s ✅"
SLSKD_CONNECTED=true
break
fi
log " waiting... (${_ELAPSED}s / 60s)"
done
[[ "$SLSKD_CONNECTED" != true ]] && \
warn "slskd still disconnected after 60s — skipping API-dependent sections"
fi
fi
# ==============================================================================================
# ━━━ slskd — Stuck Searches ━━━
# ==============================================================================================
# Clears searches in Completed/Errored state left by Soularr crashes.
# Prevents 409 Conflict error on next Soularr startup when it tries to
# create a search with the same ID that already exists in a terminal state.
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then
echo ""
echo "━━━ 🔍 slskd — Stuck Searches ━━━"
SEARCHES=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/searches" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
if [[ -z "$SEARCHES" ]]; then
warn "slskd not reachable — skipping searches"
else
IDS=$(echo "$SEARCHES" | tr '{' '\n' | \
grep '"isComplete":true' | grep '"searchText":' | \
grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//')
COUNT=$(echo "$IDS" | grep -c . 2>/dev/null || echo 0)
COUNT="${COUNT//[^0-9]/}"; COUNT="${COUNT:-0}"
if [[ "$COUNT" -eq 0 ]]; then
success "No stuck searches found ✅"
else
log "Found $COUNT stuck search(es)"
SUCCESS=0; FAIL=0
while IFS= read -r ID; do
[[ -z "$ID" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete search: $ID"
((SUCCESS++))
continue
fi
RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \
"$SLSKD_URL/api/v0/searches/$ID" \
-H "X-Api-Key: $SLSKD_API_KEY")
if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then
log "$ICON_TRASH Cleared search: $ID"
((SUCCESS++))
else
error "Failed: $ID (HTTP $RESULT)"
((FAIL++))
fi
done <<< "$IDS"
success "Searches: $SUCCESS cleared, $FAIL failed"
(( TOTAL_FAIL += FAIL ))
(( TOTAL_PASS += SUCCESS ))
fi
fi
fi
# ==============================================================================================
# ━━━ slskd — Dead Transfer Records ━━━
# ==============================================================================================
# Removes completed/errored/aborted transfer records per user.
# Prevents Soularr 404 loop when polling a user whose transfer no longer exists.
# Safety: NEVER removes transfers that are InProgress or Queued — active downloads protected.
if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then
echo ""
echo "━━━ 🔍 slskd — Dead Transfer Records ━━━"
TRANSFERS=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/transfers/downloads" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
if [[ -z "$TRANSFERS" ]]; then
warn "slskd not reachable — skipping transfers"
else
USERNAMES=$(echo "$TRANSFERS" | grep -o '"username":"[^"]*"' | \
sed 's/"username":"//;s/"//' | sort -u)
if [[ -z "$USERNAMES" ]]; then
success "No transfer records found ✅"
else
USER_COUNT=$(echo "$USERNAMES" | grep -c . 2>/dev/null || echo 0)
log "Found $USER_COUNT user(s) with transfer records"
SUCCESS=0; SKIPPED=0; FAIL=0
while IFS= read -r USER; do
[[ -z "$USER" ]] && continue
USER_DATA=$(curl -sf --max-time 10 \
"$SLSKD_URL/api/v0/transfers/downloads/$USER" \
-H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null)
# Skip users with any active or queued transfers — never interrupt downloads
ACTIVE=$(echo "$USER_DATA" | grep -c '"state":"InProgress"\|"state":"Queued"')
if [[ "${ACTIVE:-0}" -gt 0 ]]; then
log "$ICON_SKIP Skipping $USER — has active/queued transfer(s)"
((SKIPPED++))
continue
fi
# Extract IDs of terminal-state file transfers
# Split at { so each file object lands on its own line, then grep for state
FILE_IDS=$(echo "$USER_DATA" | tr '{' '\n' | \
grep '"state":"Completed"\|"state":"Errored"\|"state":"Aborted"\|"state":"Cancelled"' | \
grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//')
if [[ -z "$FILE_IDS" ]]; then
log "$ICON_SKIP Skipping $USER — no terminal-state transfers"
((SKIPPED++))
continue
fi
if [[ "$DRY_RUN" == true ]]; then
F_COUNT=$(echo "$FILE_IDS" | grep -c .)
warn "DRY RUN — would clear $F_COUNT transfer(s) for: $USER"
((SUCCESS++))
continue
fi
F_SUCCESS=0; F_FAIL=0
while IFS= read -r FILE_ID; do
[[ -z "$FILE_ID" ]] && continue
RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \
"$SLSKD_URL/api/v0/transfers/downloads/$USER/$FILE_ID" \
-H "X-Api-Key: $SLSKD_API_KEY")
if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then
((F_SUCCESS++))
else
((F_FAIL++))
fi
done <<< "$FILE_IDS"
log "$ICON_TRASH Cleared $F_SUCCESS transfer(s) for: $USER ($F_FAIL failed)"
((SUCCESS += F_SUCCESS))
((FAIL += F_FAIL))
done <<< "$USERNAMES"
success "Transfers: $SUCCESS cleared, $SKIPPED skipped (active/empty), $FAIL failed"
(( TOTAL_FAIL += FAIL ))
(( TOTAL_PASS += SUCCESS ))
fi
fi
fi
# ==============================================================================================
# ━━━ slskd — Purge Expired Failed Imports ━━━
# ==============================================================================================
# Removes albums Soularr downloaded but Lidarr rejected.
# Soularr moves rejected albums to failed_imports/ and never cleans them up.
# Purges directories older than DOWNLOADER_RETENTION_DAYS to prevent unbounded growth.
if [[ -n "$SLSKD_FAILED_IMPORTS_DIR" ]]; then
echo ""
echo "━━━ 🔍 slskd — Failed Imports (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
if [[ ! -d "$SLSKD_FAILED_IMPORTS_DIR" ]]; then
warn "Directory not found: $SLSKD_FAILED_IMPORTS_DIR — skipping"
else
OLD_IMPORTS=$(find "$SLSKD_FAILED_IMPORTS_DIR" \
-mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}")
IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || echo 0)
IMPORT_COUNT="${IMPORT_COUNT//[^0-9]/}"; IMPORT_COUNT="${IMPORT_COUNT:-0}"
if [[ "$IMPORT_COUNT" -eq 0 ]]; then
success "No expired failed imports found ✅"
else
log "Found $IMPORT_COUNT expired failed import(s)"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete:"
echo "$OLD_IMPORTS"
else
find "$SLSKD_FAILED_IMPORTS_DIR" \
-mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}" \
-exec rm -rf {} \;
success "$ICON_TRASH Purged $IMPORT_COUNT expired failed import(s)"
(( TOTAL_PASS += IMPORT_COUNT ))
fi
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Clear Completed History ━━━
# ==============================================================================================
# Removes completed download history older than DOWNLOADER_RETENTION_DAYS.
# Keeps recent history for reference — only purges what's past the retention window.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Completed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
HISTORY=$(curl -sf --max-time 15 \
"$SABNZBD_URL/api?mode=history&output=json&limit=1000&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$HISTORY" ]]; then
warn "SABnzbd not reachable — skipping completed history"
else
COMPLETED_IDS=$(echo "$HISTORY" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$COMPLETED_IDS" ]]; then
success "No completed history found ✅"
else
HIST_TOTAL=$(echo "$COMPLETED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $HIST_TOTAL completed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
JOB_TIME=$(echo "$HISTORY" | grep -A5 "$NZO_ID" | \
grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1)
[[ -z "$JOB_TIME" ]] && continue
[[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete completed job: $NZO_ID"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Deleted: $NZO_ID"
((DELETED++))
fi
done <<< "$COMPLETED_IDS"
success "Completed: $DELETED deleted, $SKIPPED within retention"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Clear Failed History ━━━
# ==============================================================================================
# Removes failed download history older than DOWNLOADER_RETENTION_DAYS.
# Failed history is kept briefly for diagnosis but purged after the retention window.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Failed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━"
FAILED_HIST=$(curl -sf --max-time 15 \
"$SABNZBD_URL/api?mode=history&output=json&limit=1000&failed_only=1&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$FAILED_HIST" ]]; then
warn "SABnzbd not reachable — skipping failed history"
else
FAILED_IDS=$(echo "$FAILED_HIST" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$FAILED_IDS" ]]; then
success "No failed history found ✅"
else
FAILED_TOTAL=$(echo "$FAILED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $FAILED_TOTAL failed history entries"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
JOB_TIME=$(echo "$FAILED_HIST" | grep -A5 "$NZO_ID" | \
grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1)
[[ -z "$JOB_TIME" ]] && continue
[[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete failed job: $NZO_ID"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Deleted: $NZO_ID"
((DELETED++))
fi
done <<< "$FAILED_IDS"
success "Failed: $DELETED deleted, $SKIPPED within retention"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ SABnzbd — Remove Stalled Queue Items ━━━
# ==============================================================================================
# Removes queue items in Paused or Stuck state that are no longer progressing.
# Active downloading items (Downloading, Grabbing) are never touched.
# Paused items may be intentional pauses — but in an automated environment
# a Paused item sitting in the queue indefinitely is effectively stalled.
if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then
echo ""
echo "━━━ 🔍 SABnzbd — Stalled Queue Items ━━━"
QUEUE=$(curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=queue&output=json&apikey=$SABNZBD_API_KEY" 2>/dev/null)
if [[ -z "$QUEUE" ]]; then
warn "SABnzbd not reachable — skipping queue"
else
STALLED_IDS=$(echo "$QUEUE" | grep -o '"nzo_id":"[^"]*"' | \
sed 's/"nzo_id":"//;s/"//')
if [[ -z "$STALLED_IDS" ]]; then
success "No stalled queue items found ✅"
else
QUEUE_TOTAL=$(echo "$STALLED_IDS" | grep -c . 2>/dev/null || echo 0)
log "Found $QUEUE_TOTAL queue item(s) — checking status"
DELETED=0; SKIPPED=0
while IFS= read -r NZO_ID; do
[[ -z "$NZO_ID" ]] && continue
STATUS=$(echo "$QUEUE" | grep -A10 "$NZO_ID" | \
grep -o '"status":"[^"]*"' | sed 's/"status":"//;s/"//')
# Only remove Paused or Stuck items — Downloading/Grabbing are active
if [[ "$STATUS" != "Paused" ]] && [[ "$STATUS" != "Stuck" ]]; then
((SKIPPED++))
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove stalled item: $NZO_ID ($STATUS)"
((DELETED++))
else
curl -sf --max-time 10 \
"$SABNZBD_URL/api?mode=queue&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \
>/dev/null
log "$ICON_TRASH Removed stalled ($STATUS): $NZO_ID"
((DELETED++))
fi
done <<< "$STALLED_IDS"
success "Queue: $DELETED removed, $SKIPPED active (skipped)"
(( TOTAL_PASS += DELETED ))
fi
fi
fi
# ==============================================================================================
# ━━━ qBittorrent — Age Failsafe Cleanup ━━━
# ==============================================================================================
# Last-chance cleanup for torrents that have been sitting in qBit past their useful life.
# deleteFiles=false — removes the torrent record from qBit but leaves files on disk.
# Radarr/Sonarr manage actual files independently — this only cleans up the qBit entry.
#
# Safety checks before deletion:
# Age must exceed QBIT_FAILSAFE_MIN_DAYS
# Ratio must meet QBIT_FAILSAFE_MIN_RATIO (0 = age only, no ratio requirement)
if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then
echo ""
echo "━━━ 🔍 qBittorrent — Failsafe (older than ${QBIT_FAILSAFE_MIN_DAYS} days) ━━━"
[[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]] && \
log "Ratio requirement: >= ${QBIT_FAILSAFE_MIN_RATIO}"
QBIT_COOKIE=$(curl -sf --max-time 10 -c - \
"$QBIT_URL/api/v2/auth/login" \
--data "username=$QBIT_USERNAME&password=$QBIT_PASSWORD" 2>/dev/null | \
grep SID | awk '{print "SID="$NF}')
if [[ -z "$QBIT_COOKIE" ]]; then
error "Failed to authenticate with qBittorrent — check QBIT_USERNAME/PASSWORD"
notify "qBittorrent auth failed on $(hostname) — check credentials in host*.conf" "Downloaders Reset" "warning"
((TOTAL_FAIL++))
else
TORRENTS=$(curl -sf --max-time 15 \
"$QBIT_URL/api/v2/torrents/info" \
-H "Cookie: $QBIT_COOKIE" 2>/dev/null)
NOW=$(date +%s)
TORRENT_TOTAL=$(echo "$TORRENTS" | tr '}' '\n' | grep -c '"hash"' 2>/dev/null || echo 0)
log "Found $TORRENT_TOTAL torrent(s) — applying age/ratio filter"
DELETED=0; SKIPPED=0
while read -r TORRENT; do
[[ -z "$TORRENT" ]] && continue
HASH=$(echo "$TORRENT" | grep -o '"hash":"[^"]*"' | sed 's/"hash":"//;s/"//')
NAME=$(echo "$TORRENT" | grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//')
ADDED=$(echo "$TORRENT" | grep -o '"added_on":[0-9]*' | grep -o '[0-9]*')
RATIO=$(echo "$TORRENT" | grep -o '"ratio":[0-9.]*' | grep -o '[0-9.]*')
[[ -z "$HASH" || -z "$ADDED" ]] && continue
AGE_DAYS=$(( (NOW - ADDED) / 86400 ))
# Age check — must be old enough
[[ "$AGE_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue
# Ratio check — if configured
if [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]]; then
RATIO_INT="${RATIO%.*}"
MIN_RATIO_INT="${QBIT_FAILSAFE_MIN_RATIO%.*}"
[[ "$RATIO_INT" -lt "$MIN_RATIO_INT" ]] && ((SKIPPED++)) && continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete: $NAME (${AGE_DAYS}d old, ratio: $RATIO)"
((DELETED++))
else
curl -sf --max-time 10 -X POST \
"$QBIT_URL/api/v2/torrents/delete" \
-H "Cookie: $QBIT_COOKIE" \
--data "hashes=$HASH&deleteFiles=false" >/dev/null
log "$ICON_TRASH Deleted: $NAME (${AGE_DAYS}d old, ratio: $RATIO)"
((DELETED++))
fi
done < <(echo "$TORRENTS" | tr '}' '\n')
success "qBittorrent: $DELETED deleted, $SKIPPED skipped (under threshold)"
(( TOTAL_PASS += DELETED ))
fi
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DOWNLOADERS RESET SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( $(date +%s) - START_TIME )))"
echo "$ICON_SUCCESS Actions: $TOTAL_PASS"
echo "$ICON_ERROR Failures: $TOTAL_FAIL"
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -gt 0 ]]; then
echo "$ICON_ERROR Status: $TOTAL_FAIL failure(s) — check logs"
notify "Downloaders reset completed with failures on $(hostname)" "Downloaders Reset" "warning"
exit 1
else
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,330 @@
#!/bin/bash
# ==============================================================================================
# ============================= Backup Verify ==================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# rsync mirror integrity verification via independent MD5 checksums. Scheduled
# weekly (Sunday 10am). Randomly samples BACKUP_VERIFY_SAMPLE files per share
# above BACKUP_VERIFY_MIN_SIZE, computes checksums locally, then computes the
# same checksums on the remote via SSH and compares.
#
# Per file: MATCH (checksums identical) | MISMATCH (file exists on both but
# checksums differ — sync failure or corruption) | MISSING (file exists locally
# but not on remote). All MISMATCHes and significant MISSINGs trigger notification.
# rsync exit code 0 is not trusted — this script verifies actual content.
#
# Share list from HOST*_BACKUP_VERIFY_SHARES if defined, otherwise falls back
# to HOST*_DAILY_SYNC_SHARES. Both aliased by detect_hosts().
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Independent Verification
# rsync reports success when the transfer completed without network errors and
# file sizes and modification times match. It does not detect silent corruption
# during transfer (bitflip in transit), corruption written to storage at rest
# (faulty drive sector), or files that matched size/mtime but had wrong content.
# All of these produce exit code 0. This script checks whether "done" means "correct."
#
# Intentionally Small Sample
# 10 files per share (default) — a spot check, not an exhaustive verify.
# Catches systematic problems and hardware issues while running in minutes, not
# hours. Full verification would take longer than the rsync itself.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent runs producing conflicting results.
#
# Remote Connectivity Check
# check_connectivity() verifies the remote Tailscale IP is reachable before
# any SSH calls. Without this, all files show as MISSING on a network hiccup.
#
# Remote Array Check
# check_remote_array() verifies /mnt/user is mounted on the remote before
# computing checksums. Array not started = all files "missing" = false alarm.
#
# Version Parity
# Refuses to run if remote unRAID version doesn't match local. A mismatch
# may mean the remote is in an unexpected state.
#
# SSH Timeout
# SSH_TIMEOUT caps all SSH calls. One hung connection does not block the run.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_BACKUP_VERIFY_SHARES
# Shares to verify. Leave empty to use HOST*_DAILY_SYNC_SHARES automatically.
# Aliased by detect_hosts() → BACKUP_VERIFY_SHARES.
#
# HOST*_DAILY_SYNC_SHARES
# Fallback share list if BACKUP_VERIFY_SHARES is empty. Aliased by detect_hosts().
#
# master.conf
#
# BACKUP_VERIFY_SAMPLE
# Random files checked per share per run. (default: 10)
#
# BACKUP_VERIFY_MIN_SIZE
# Minimum file size to include in sample — tiny files have low corruption
# risk and slow checksums. (default: 1M)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# backup_verify.sh
# Sample files from all shares and compare checksums. Notify on MISMATCH
# or significant MISSING count. Silent when all samples match.
#
# backup_verify.sh --dry-run
# Show which files would be sampled. No checksums computed, no notifications.
#
# backup_verify.sh --status
# Show share list, sample size, and min file size configuration. Then exit.
#
# backup_verify.sh --log
# Verbose per-file checksum comparison output during the run.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
SSH_TIMEOUT=15
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID and aliases BACKUP_VERIFY_SHARES + DAILY_SYNC_SHARES
detect_hosts
# Share selection — configured list or fallback to daily sync shares
if [[ ${#BACKUP_VERIFY_SHARES[@]} -gt 0 ]]; then
VERIFY_SHARES=("${BACKUP_VERIFY_SHARES[@]}")
log "Using BACKUP_VERIFY_SHARES (${#VERIFY_SHARES[@]} shares)"
else
VERIFY_SHARES=("${DAILY_SYNC_SHARES[@]}")
log "BACKUP_VERIFY_SHARES not set — using DAILY_SYNC_SHARES (${#VERIFY_SHARES[@]} shares)"
fi
if [[ ${#VERIFY_SHARES[@]} -eq 0 ]]; then
warn "No shares configured for $MY_ID — nothing to verify"
warn "Check HOST*_BACKUP_VERIFY_SHARES or HOST*_DAILY_SYNC_SHARES in host*.conf"
exit 0
fi
log "$ICON_GEAR Config: sample=${BACKUP_VERIFY_SAMPLE} min-size=${BACKUP_VERIFY_MIN_SIZE} ssh-timeout=${SSH_TIMEOUT}s"
log "$ICON_GEAR Remote: $REMOTE_ID ($REMOTE_SERVER_NAME — $REMOTE_SERVER)"
log "$ICON_GEAR Shares: ${VERIFY_SHARES[*]}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — showing sample selection only, no checksums computed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME — $REMOTE_SERVER)"
echo "$ICON_VERIFY Shares: ${#VERIFY_SHARES[@]}"
echo "$ICON_VERIFY Sample: $BACKUP_VERIFY_SAMPLE files per share"
echo "$ICON_VERIFY Min size: $BACKUP_VERIFY_MIN_SIZE"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo ""
echo " Shares to verify:"
for share in "${VERIFY_SHARES[@]}"; do
echo " $share"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
resolve_remote_ip
# Connectivity — no point making 100+ SSH calls if remote is unreachable
check_connectivity
log "Connectivity to $REMOTE_SERVER_NAME ✅"
# Version parity — mismatched unRAID could cause md5sum path differences
check_os_version_parity || {
warn "Version parity check failed — proceeding with caution"
warn "Checksum results may be unreliable if md5sum path changed between versions"
}
log "Version parity with $REMOTE_SERVER_NAME ✅"
# Remote array — if array is down all files appear "missing" = false alarm
if ! check_remote_array; then
error "Remote array not mounted on $REMOTE_SERVER_NAME"
error "All files would appear as MISSING — aborting to prevent false alarm"
notify "Backup verify aborted on $(hostname) — remote array not mounted on $REMOTE_SERVER_NAME" \
"Backup Verify" "warning"
exit 1
fi
log "Remote array mounted on $REMOTE_SERVER_NAME ✅"
echo "Pre-flight passed ✅"
# ==============================================================================================
# ━━━ Backup Verification ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_VERIFY Backup Verification — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_VERIFY Sample: $BACKUP_VERIFY_SAMPLE files per share (min: $BACKUP_VERIFY_MIN_SIZE)"
echo ""
START=$(date +%s)
TOTAL_CHECKED=0
TOTAL_MATCH=0
TOTAL_MISMATCH=0
TOTAL_MISSING=0
SHARES_WITH_ISSUES=()
for share in "${VERIFY_SHARES[@]}"; do
SHARE_NAME=$(basename "$share")
echo "━━━ $ICON_VERIFY $SHARE_NAME ━━━"
if [[ ! -d "$share" ]]; then
warn "$SHARE_NAME not found locally — skipping"
echo ""
continue
fi
# Sample random files above minimum size
mapfile -t SAMPLE_FILES < <(
find "$share" -type f -size +"$BACKUP_VERIFY_MIN_SIZE" 2>/dev/null | \
shuf | head -n "$BACKUP_VERIFY_SAMPLE"
)
if [[ ${#SAMPLE_FILES[@]} -eq 0 ]]; then
log "$SHARE_NAME — no files found above $BACKUP_VERIFY_MIN_SIZE"
echo ""
continue
fi
log "$SHARE_NAME — sampled ${#SAMPLE_FILES[@]} files"
if [[ "$DRY_RUN" == true ]]; then
for f in "${SAMPLE_FILES[@]}"; do
warn "DRY RUN — would check: $(basename "$f")"
done
echo ""
continue
fi
SHARE_MATCH=0
SHARE_MISMATCH=0
SHARE_MISSING=0
for local_file in "${SAMPLE_FILES[@]}"; do
[[ -z "$local_file" ]] && continue
# Local checksum
local_md5=$(md5sum "$local_file" 2>/dev/null | awk '{print $1}')
if [[ -z "$local_md5" ]]; then
warn "Could not checksum locally: $(basename "$local_file") — skipping"
continue
fi
# Remote checksum via SSH — timeout protected
remote_md5=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum '$local_file' 2>/dev/null | awk '{print \$1}'" 2>/dev/null)
(( TOTAL_CHECKED++ ))
if [[ -z "$remote_md5" ]]; then
warn "$ICON_ERROR MISSING: $(basename "$local_file")"
(( SHARE_MISSING++ ))
(( TOTAL_MISSING++ ))
elif [[ "$local_md5" == "$remote_md5" ]]; then
log "MATCH: $(basename "$local_file")"
(( SHARE_MATCH++ ))
(( TOTAL_MATCH++ ))
else
error "MISMATCH: $(basename "$local_file")"
error " local: $local_md5"
error " remote: $remote_md5"
(( SHARE_MISMATCH++ ))
(( TOTAL_MISMATCH++ ))
fi
done
# Per-share result — only visible if issues found
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH"
SHARES_WITH_ISSUES+=("$SHARE_NAME")
else
log "$SHARE_NAME — all $SHARE_MATCH files match ✅"
fi
echo ""
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY BACKUP VERIFY SUMMARY ━━━━━"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_VERIFY Checked: $TOTAL_CHECKED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
echo "$ICON_SUCCESS Match: $TOTAL_MATCH"
warn "Missing: $TOTAL_MISSING"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no checksums computed"
elif [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then
echo "$ICON_ERROR Status: ISSUES FOUND — ${#SHARES_WITH_ISSUES[@]} share(s) need attention: ${SHARES_WITH_ISSUES[*]}"
notify "Backup verify FAILED on $(hostname) → $REMOTE_SERVER_NAME — mismatches: $TOTAL_MISMATCH missing: $TOTAL_MISSING — shares: ${SHARES_WITH_ISSUES[*]}" \
"Backup Verify" "warning"
else
echo "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,382 @@
#!/bin/bash
# ==============================================================================================
# ============================= Emby Database Repair ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Stops Emby, runs SQLite PRAGMA integrity_check on all Emby databases, and
# restarts. Use when Emby reports corruption, unexpected crashes, or playback
# state issues.
#
# Reports which databases are corrupted. Does NOT automatically repair.
# Repair requires manual steps — guidance is printed in the summary output.
# Always take a backup before deleting any database file.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Databases Checked
# library.db — media library metadata (largest, most critical)
# library.db-wal — write-ahead log (if present — uncommitted transactions)
# librarydb.db — legacy library database
# users.db — user accounts and settings
# authentication.db — API keys and sessions
# activity.db — activity log (least critical, safe to delete if corrupt)
#
# Missing databases are skipped gracefully — not all files exist on all setups.
# Emby's config path is detected from the Docker mount — no hardcoded paths.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Guaranteed Restart
# EXIT trap ensures Emby is always restarted even if the script crashes
# mid-check — Emby is never left stopped due to a script error.
#
# Docker Timeout
# DOCKER_TIMEOUT (30s) protects all docker calls against a hung daemon.
# Emby can take time to stop cleanly — 30s is intentionally generous.
#
# Tool Validation
# platform_require_cmd confirms sqlite3 and the notify script are present
# before use. jq is checked separately — required for config path detection.
#
# Post-Restart Verify
# Checks that Emby is still running after restart — detects cases where
# Emby crashes immediately after start (which would indicate deeper trouble).
#
# Silent When Healthy
# Only corruption produces visible output and a notification.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_EMBY_CONTAINER
# Name of the Emby Docker container on this host.
# Aliased by detect_hosts() → EMBY_CONTAINER.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# emby_database_repair.sh
# Stop Emby, check all databases with PRAGMA integrity_check, restart.
#
# emby_database_repair.sh --dry-run
# Show which databases would be checked and Emby container name. No stop.
#
# emby_database_repair.sh --log
# Verbose output with per-database check result.
#
# emby_database_repair.sh --status
# Show Emby container name and config path detected from Docker. Then exit.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
DOCKER_TIMEOUT=30 # Emby can take time to stop cleanly
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# Validate required tools
platform_require_cmd \
"$(command -v sqlite3 2>/dev/null || echo /usr/bin/sqlite3)" \
"--version" "." \
"sqlite3" || { error "sqlite3 not found — install sqlite package"; exit 1; }
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required to detect Emby config path from Docker mounts"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID and aliases HOST*_EMBY_CONTAINER → EMBY_CONTAINER
detect_hosts
if [[ -z "${EMBY_CONTAINER:-}" ]]; then
error "EMBY_CONTAINER not set for $MY_ID — check HOST*_EMBY_CONTAINER in host*.conf"
exit 1
fi
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Emby container: $EMBY_CONTAINER"
# Detect Emby config path from Docker container mounts
EMBY_CONFIG_HOST=$(timeout "$DOCKER_TIMEOUT" docker inspect "$EMBY_CONTAINER" 2>/dev/null | \
jq -r '.[] | .Mounts[] | select(.Destination == "/config") | .Source' 2>/dev/null)
if [[ -z "$EMBY_CONFIG_HOST" ]]; then
error "Could not detect Emby config path from Docker mounts"
error "Is $EMBY_CONTAINER the correct container name? Check HOST*_EMBY_CONTAINER in host*.conf"
exit 1
fi
log "Emby config: $EMBY_CONFIG_HOST"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — Emby will not be stopped, no checks run"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_EMBY Container: $EMBY_CONTAINER"
echo "$ICON_EMBY Config: $EMBY_CONFIG_HOST"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo ""
echo "━━━ Database Files ━━━"
for db_rel in "data/library.db" "data/library.db-wal" "data/librarydb.db" \
"data/users.db" "data/authentication.db" "data/activity.db"; do
db_path="${EMBY_CONFIG_HOST}/${db_rel}"
db_name=$(basename "$db_rel")
if [[ -f "$db_path" ]]; then
db_size=$(du -sh "$db_path" 2>/dev/null | cut -f1)
echo " $ICON_SUCCESS $db_name ($db_size)"
else
echo " $ICON_SKIP $db_name — not found"
fi
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── EXIT trap — Emby always restarted if it was running ───────────────────────────────────────
EMBY_WAS_RUNNING=false
cleanup_on_exit() {
local exit_code=$?
if [[ "$EMBY_WAS_RUNNING" == true && "$DRY_RUN" == false ]]; then
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$EMBY_CONTAINER" 2>/dev/null)
if [[ "$status" != "true" ]]; then
warn "Restarting $EMBY_CONTAINER (cleanup)..."
timeout "$DOCKER_TIMEOUT" docker start "$EMBY_CONTAINER" >/dev/null 2>&1 || \
error "Failed to restart $EMBY_CONTAINER — start it manually"
fi
fi
}
trap cleanup_on_exit EXIT
# ==============================================================================================
# ━━━ Stop Emby ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP Stop Emby ━━━"
STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$EMBY_CONTAINER" 2>/dev/null)
case "$STATUS" in
true)
EMBY_WAS_RUNNING=true
warn "Stopping $EMBY_CONTAINER — active sessions will be interrupted"
if [[ "$DRY_RUN" == false ]]; then
if timeout "$DOCKER_TIMEOUT" docker stop "$EMBY_CONTAINER" >/dev/null 2>&1; then
log "$EMBY_CONTAINER stopped ✅"
sleep 3 # let file handles release
else
error "Failed to stop $EMBY_CONTAINER — aborting"
exit 1
fi
else
warn "DRY RUN — would stop $EMBY_CONTAINER"
fi
;;
false)
log "$EMBY_CONTAINER is not running — proceeding with checks"
;;
"")
error "$EMBY_CONTAINER not found — check container name"
exit 1
;;
*)
warn "$EMBY_CONTAINER status: $STATUS — proceeding with caution"
;;
esac
# ==============================================================================================
# ━━━ Database Integrity Check ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_HEALTH Database Integrity Check ━━━"
START=$(date +%s)
DB_FILES=(
"data/library.db"
"data/library.db-wal"
"data/librarydb.db"
"data/users.db"
"data/authentication.db"
"data/activity.db"
)
PASS_DBS=()
FAIL_DBS=()
MISSING_DBS=()
for db_rel in "${DB_FILES[@]}"; do
db_path="${EMBY_CONFIG_HOST}/${db_rel}"
db_name=$(basename "$db_rel")
if [[ ! -f "$db_path" ]]; then
log "$db_name — not found, skipping"
MISSING_DBS+=("$db_name")
continue
fi
DB_SIZE=$(du -sh "$db_path" 2>/dev/null | cut -f1)
log "Checking $db_name ($DB_SIZE)..."
# WAL file — different check (not a full SQLite database)
if [[ "$db_name" == *.wal ]]; then
if [[ -s "$db_path" ]]; then
warn "$db_name exists and is non-empty (${DB_SIZE})"
warn "Uncommitted WAL data — will be merged when Emby next starts cleanly"
PASS_DBS+=("$db_name (WAL — see warning)")
else
log "$db_name exists but is empty — no pending transactions ✅"
PASS_DBS+=("$db_name")
fi
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would check: $db_path"
continue
fi
# Full integrity check
RESULT=$(sqlite3 "$db_path" "PRAGMA integrity_check;" 2>/dev/null)
EXIT_CODE=$?
if [[ "$EXIT_CODE" -ne 0 ]]; then
error "$db_name — sqlite3 could not open database (locked or corrupt)"
FAIL_DBS+=("$db_name")
elif [[ "$RESULT" == "ok" ]]; then
log "$db_name — integrity check passed ✅"
PASS_DBS+=("$db_name")
else
error "$db_name — CORRUPTION DETECTED"
echo "$RESULT" | head -10 | while IFS= read -r line; do
error " $line"
done
FAIL_DBS+=("$db_name")
fi
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Restart Emby ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_START Restart Emby ━━━"
RESTART_OK=false
if [[ "$EMBY_WAS_RUNNING" == true ]]; then
if [[ "$DRY_RUN" == false ]]; then
log "Restarting $EMBY_CONTAINER..."
if timeout "$DOCKER_TIMEOUT" docker start "$EMBY_CONTAINER" >/dev/null 2>&1; then
sleep 5 # Emby takes longer to initialise than most containers
POST_STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$EMBY_CONTAINER" 2>/dev/null)
if [[ "$POST_STATUS" == "true" ]]; then
log "$EMBY_CONTAINER restarted and running ✅"
RESTART_OK=true
else
error "$EMBY_CONTAINER started but crashed — database may be corrupt"
error "Check Docker logs: docker logs $EMBY_CONTAINER"
notify "$EMBY_CONTAINER crashed on restart — possible database corruption on $(hostname)" \
"Emby DB Repair" "warning"
fi
else
error "Failed to restart $EMBY_CONTAINER — start it manually"
notify "$EMBY_CONTAINER failed to restart after integrity check on $(hostname)" \
"Emby DB Repair" "warning"
fi
else
warn "DRY RUN — would restart $EMBY_CONTAINER"
RESTART_OK=true
fi
else
log "$EMBY_CONTAINER was not running — leaving stopped (state respected) ✅"
RESTART_OK=true
fi
# Clear EXIT trap — clean exit
trap - EXIT
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY EMBY DATABASE REPAIR SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_EMBY Container: $EMBY_CONTAINER"
echo "$ICON_EMBY Config: $EMBY_CONFIG_HOST"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
echo " $ICON_SUCCESS Passed: ${#PASS_DBS[@]}"
[[ ${#FAIL_DBS[@]} -gt 0 ]] && echo " $ICON_ERROR Failed: ${#FAIL_DBS[@]}"
[[ ${#MISSING_DBS[@]} -gt 0 ]] && echo " Skipped: ${#MISSING_DBS[@]} (not found)"
echo ""
[[ ${#PASS_DBS[@]} -gt 0 ]] && for db in "${PASS_DBS[@]}"; do log " $ICON_SUCCESS $db"; done
[[ ${#FAIL_DBS[@]} -gt 0 ]] && for db in "${FAIL_DBS[@]}"; do echo " $ICON_ERROR $db"; done
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no checks performed"
elif [[ ${#FAIL_DBS[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: CORRUPTION FOUND — manual intervention needed"
echo ""
echo "$ICON_INFO Next steps per corrupted database:"
echo " library.db — Delete file, restart Emby — rebuilds from media (slow first start)"
echo " library.db-wal — Delete WAL file, restart Emby — safe, no permanent data loss"
echo " librarydb.db — Delete file, restart Emby — legacy, Emby recreates"
echo " users.db — Restore from backup or delete — deleting resets all user accounts"
echo " authentication.db — Delete file, restart Emby — API keys regenerated automatically"
echo " activity.db — Delete file, restart Emby — activity log only, no media data"
echo ""
warn "⚠️ Always take a backup before deleting any database file"
warn " Run: container_data_export.sh $EMBY_CONTAINER <config_path> <backup_dir>"
notify "Emby database CORRUPTION on $(hostname) — failed: ${FAIL_DBS[*]} — manual intervention needed" \
"Emby DB Repair" "warning"
else
echo "$ICON_DONE Status: all ${#PASS_DBS[@]} databases healthy ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAIL_DBS[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,312 @@
#!/bin/bash
# ==============================================================================================
# ============================= Container Data Export ==========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Exports a container's appdata directory to a compressed tar archive. Stops
# the container before archiving and restarts it after — ensures a clean,
# consistent backup. Use before major updates, pool migrations, destructive
# appdata operations, or when archiving a container being removed from the stack.
#
# Output: ContainerName_YYYY-MM-DD_HH-MM.tar.gz — timestamped, no overwrite.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Archive Verification Before Restart
# The archive is tested with tar --test-file before the container is restarted.
# A corrupt archive is not a usable backup — this catches tar failures, I/O
# errors, and truncated writes before declaring success. If verification fails,
# the container is still restarted (appdata is unchanged) and an error logged.
#
# Conservative Space Estimate
# Required space is estimated as appdata size × 1.1 (10% buffer). The actual
# compressed archive will typically be much smaller — database files compress
# well, media files do not. The estimate is a conservative floor, not a
# prediction.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Container Restart Rule
# Tracks whether the container was running before the export. Running containers
# are restarted after completion; already-stopped containers are left stopped.
# The restart happens on every exit path — a failed tar does not leave the
# container stuck stopped.
#
# Partial Archive Cleanup
# If tar fails, the incomplete archive is removed. A partial archive is worse
# than no archive — it can look valid but restore to an incomplete state.
#
# Docker Timeout
# DOCKER_TIMEOUT (default: 30s) caps all docker calls. Guards against a hung
# daemon blocking the script indefinitely.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir
# Stop container, create archive, verify, restart container.
# Example: container_data_export.sh Emby /mnt/media-servers/.../Emby /mnt/user/Backups/
#
# container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir --dry-run
# Show what would be archived and estimated size. No container stop, no tar.
#
# container_data_export.sh ContainerName /path/to/appdata /path/to/output/dir --log
# Verbose output: space check, tar progress, verification result.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
DOCKER_TIMEOUT=30 # longer timeout — stop can take time on large containers
# ── Positional args ───────────────────────────────────────────────────────────────────────────
CONTAINER_NAME="${PARSED_ARGS[0]:-}"
APPDATA_PATH="${PARSED_ARGS[1]:-}"
OUTPUT_DIR="${PARSED_ARGS[2]:-}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID — used in summary
detect_hosts
# Arg validation
if [[ -z "$CONTAINER_NAME" || -z "$APPDATA_PATH" || -z "$OUTPUT_DIR" ]]; then
error "Usage: container_data_export.sh <ContainerName> <appdata_path> <output_dir>"
error "Example: container_data_export.sh Emby /mnt/media-servers/Media_Server/Emby /mnt/user/Backups/"
exit 1
fi
if [[ ! -d "$APPDATA_PATH" ]]; then
error "Appdata path not found: $APPDATA_PATH"
exit 1
fi
if [[ ! -d "$OUTPUT_DIR" ]]; then
error "Output directory not found: $OUTPUT_DIR"
error "Create it first: mkdir -p \"$OUTPUT_DIR\""
exit 1
fi
# Space check — conservative: appdata × 1.1
APPDATA_SIZE_KB=$(du -sk "$APPDATA_PATH" 2>/dev/null | cut -f1)
OUTPUT_FREE_KB=$(df "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ')
REQUIRED_KB=$(( APPDATA_SIZE_KB * 11 / 10 ))
APPDATA_SIZE_H=$(du -sh "$APPDATA_PATH" 2>/dev/null | cut -f1)
OUTPUT_FREE_H=$(df -h "$OUTPUT_DIR" --output=avail 2>/dev/null | tail -1 | tr -d ' ')
if [[ "$OUTPUT_FREE_KB" -lt "$REQUIRED_KB" ]]; then
error "Insufficient space in $OUTPUT_DIR"
error "Estimated need: ~${APPDATA_SIZE_H} (×1.1 conservative) — available: ${OUTPUT_FREE_H}"
exit 1
fi
log "Container: $CONTAINER_NAME"
log "Appdata: $APPDATA_PATH ($APPDATA_SIZE_H)"
log "Output: $OUTPUT_DIR ($OUTPUT_FREE_H free)"
log "Space check passed"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure container is restarted on any exit if it was running ────────────────────────────────
CONTAINER_WAS_RUNNING=false
ARCHIVE_PATH=""
cleanup_on_exit() {
local exit_code=$?
# Remove partial archive on failure
if [[ "$exit_code" -ne 0 && -n "$ARCHIVE_PATH" && -f "$ARCHIVE_PATH" ]]; then
warn "Removing partial archive: $ARCHIVE_PATH"
rm -f "$ARCHIVE_PATH" 2>/dev/null
fi
# Always restart container if it was running
if [[ "$CONTAINER_WAS_RUNNING" == true && "$DRY_RUN" == false ]]; then
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$CONTAINER_NAME" 2>/dev/null)
if [[ "$status" != "true" ]]; then
warn "Restarting $CONTAINER_NAME (cleanup)..."
timeout "$DOCKER_TIMEOUT" docker start "$CONTAINER_NAME" >/dev/null 2>&1 || \
error "Failed to restart $CONTAINER_NAME — start it manually"
fi
fi
}
trap cleanup_on_exit EXIT
# ==============================================================================================
# ━━━ Stop Container ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP Stop Container ━━━"
STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$CONTAINER_NAME" 2>/dev/null)
case "$STATUS" in
true)
CONTAINER_WAS_RUNNING=true
log "Stopping $CONTAINER_NAME for clean export..."
if [[ "$DRY_RUN" == false ]]; then
if timeout "$DOCKER_TIMEOUT" docker stop "$CONTAINER_NAME" >/dev/null 2>&1; then
log "$CONTAINER_NAME stopped ✅"
else
error "Failed to stop $CONTAINER_NAME — aborting export"
exit 1
fi
else
warn "DRY RUN — would stop $CONTAINER_NAME"
fi
;;
false)
log "$CONTAINER_NAME is not running — archiving as-is (was stopped state respected)"
;;
"")
error "$CONTAINER_NAME not found — check container name"
exit 1
;;
*)
warn "$CONTAINER_NAME status: $STATUS — proceeding with caution"
;;
esac
# ==============================================================================================
# ━━━ Archive ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Archive ━━━"
TIMESTAMP=$(date '+%Y-%m-%d_%H-%M')
ARCHIVE_NAME="${CONTAINER_NAME}_${TIMESTAMP}.tar.gz"
ARCHIVE_PATH="${OUTPUT_DIR}/${ARCHIVE_NAME}"
warn "Creating: $ARCHIVE_PATH"
warn "Source: $APPDATA_PATH ($APPDATA_SIZE_H)"
START=$(date +%s)
ARCHIVE_VERIFIED=false
if [[ "$DRY_RUN" == false ]]; then
if tar -czf "$ARCHIVE_PATH" \
-C "$(dirname "$APPDATA_PATH")" \
"$(basename "$APPDATA_PATH")" 2>/dev/null; then
ARCHIVE_SIZE=$(du -sh "$ARCHIVE_PATH" 2>/dev/null | cut -f1)
warn "Archive created: $ARCHIVE_NAME ($ARCHIVE_SIZE)"
# Verify archive integrity before declaring success
log "Verifying archive..."
if tar --test-label -f "$ARCHIVE_PATH" 2>/dev/null || \
tar -tzf "$ARCHIVE_PATH" >/dev/null 2>&1; then
log "Archive verified ✅"
ARCHIVE_VERIFIED=true
else
error "Archive verification FAILED — archive may be corrupt"
error "Container will be restarted but DO NOT rely on this backup"
notify "Container export archive corrupt — $CONTAINER_NAME backup may be unusable" \
"Container Export" "warning"
fi
else
error "tar failed — archive creation unsuccessful"
exit 1
fi
else
warn "DRY RUN — would create: $ARCHIVE_PATH"
ARCHIVE_VERIFIED=true
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Restart Container ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_START Restart Container ━━━"
RESTART_OK=false
if [[ "$CONTAINER_WAS_RUNNING" == true ]]; then
log "Restarting $CONTAINER_NAME..."
if [[ "$DRY_RUN" == false ]]; then
if timeout "$DOCKER_TIMEOUT" docker start "$CONTAINER_NAME" >/dev/null 2>&1; then
# Brief settle then verify
sleep 3
POST_STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)
if [[ "$POST_STATUS" == "true" ]]; then
log "$CONTAINER_NAME restarted and running ✅"
RESTART_OK=true
else
error "$CONTAINER_NAME started but crashed immediately — check container logs"
notify "$CONTAINER_NAME failed to stay running after export on $(hostname)" \
"Container Export" "warning"
fi
else
error "Failed to restart $CONTAINER_NAME — start it manually"
notify "$CONTAINER_NAME failed to restart after export on $(hostname)" \
"Container Export" "warning"
fi
else
warn "DRY RUN — would restart $CONTAINER_NAME"
RESTART_OK=true
fi
else
log "$CONTAINER_NAME was not running — leaving stopped (state respected) ✅"
RESTART_OK=true
fi
# Clear trap — clean exit, cleanup_on_exit no longer needed
trap - EXIT
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY CONTAINER EXPORT SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_CONTAINERS Container: $CONTAINER_NAME"
echo "$ICON_DISK Appdata: $APPDATA_PATH ($APPDATA_SIZE_H)"
echo "$ICON_SYNC Archive: ${ARCHIVE_NAME:-DRY RUN} ${ARCHIVE_SIZE:+($ARCHIVE_SIZE)}"
echo "$ICON_SHIELD Verified: $([[ "$ARCHIVE_VERIFIED" == true ]] && echo "✅" || echo "❌ FAILED")"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$ARCHIVE_VERIFIED" == true && "$RESTART_OK" == true ]]; then
echo "$ICON_DONE Status: done — $ARCHIVE_NAME"
elif [[ "$ARCHIVE_VERIFIED" == false ]]; then
echo "$ICON_ERROR Status: archive verification FAILED — check backup before relying on it"
else
warn "Status: complete with warnings — check restart status above"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ARCHIVE_VERIFIED" == false ]] && exit 1
exit 0
@@ -0,0 +1,336 @@
#!/bin/bash
# ==============================================================================================
# ================================= Ramdisk Stop ===============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Safely stops the transcode ramdisk: redirects the transcode symlink to the
# SSD fallback before unmounting so Emby continues writing without interruption,
# then unmounts the tmpfs and updates the state file.
#
# Primary use case: stopping the current ramdisk before re-running
# ramdisk_setup.sh with new size or threshold values (setup is idempotent —
# if the ramdisk is mounted, it skips the mount and reports status, so you
# must stop it first to change the size).
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Executes in safe order:
# 1. Validate — ramdisk mounted, SSD fallback exists
# 2. Redirect symlink → SSD (Emby immediately writes to SSD instead)
# 3. Warn if active transcode files still on ramdisk (informational — not a blocker)
# 4. Unmount ramdisk tmpfs
# 5. Update /tmp/transcode_state.db → current_target=TRANSCODE_SSD
#
# The symlink redirect happens before unmount so there is no window where Emby
# has nowhere to write. Existing in-progress transcode files on the ramdisk are
# lost on unmount — warn the user but proceed (this is expected for maintenance).
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# umount requires root.
#
# Single Instance Lock
# acquire_lock prevents concurrent stop attempts.
#
# Mounted Check
# Exits cleanly if ramdisk is not mounted — nothing to do.
#
# Symlink-First Order
# Symlink is redirected before unmount — Emby never sees a broken path.
#
# transcode_manager Warning
# Warns if transcode_manager.sh is running — it may flip the symlink back
# to ramdisk on its next cycle. Stop transcode_manager before running this
# if you need the SSD redirect to hold.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# HOST*_TRANSCODE_SSD SSD fallback directory — redirect target during stop.
# Aliased by detect_hosts() → TRANSCODE_SSD.
#
# master.conf
# TRANSCODE_LINK Symlink Emby uses. Must match Emby's transcode path setting.
# RAMDISK_PATH tmpfs mount point.
# TRANSCODE_STATE_FILE Override state file path (default: ${STATE_DIR}/transcode_state.db).
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# /tmp/transcode_state.db — updated to current_target=TRANSCODE_SSD after stop.
# transcode_manager.sh reads this on its next cycle.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ramdisk_stop.sh
# Stop the ramdisk: redirect symlink → SSD, unmount, update state.
#
# ramdisk_stop.sh --dry-run
# Show what would happen without making any changes.
#
# ramdisk_stop.sh --status
# Show current mount state, symlink target, active files on ramdisk. Exit.
#
# ramdisk_stop.sh --log
# Verbose output for each step.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — umount requires root"
exit 1
fi
acquire_lock
detect_hosts
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ramdisk: $RAMDISK_PATH"
log "Fallback: $TRANSCODE_SSD"
log "Symlink: $TRANSCODE_LINK"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk path: $RAMDISK_PATH"
echo "$ICON_DISK SSD fallback: $TRANSCODE_SSD"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK"
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
FILE_COUNT=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l)
echo " $ICON_RAM Active files on ramdisk: $FILE_COUNT"
else
echo " $ICON_RAM Ramdisk: NOT mounted"
fi
if [[ -L "$TRANSCODE_LINK" ]]; then
TARGET=$(readlink "$TRANSCODE_LINK")
echo " $ICON_LINK Symlink: $TRANSCODE_LINK → $TARGET"
else
echo " $ICON_LINK Symlink: not set"
fi
if [[ -f "$STATE_FILE" ]]; then
echo ""
echo " State file ($STATE_FILE):"
while IFS='=' read -r key value; do
[[ -z "$key" ]] && continue
echo " $key = $value"
done < "$STATE_FILE"
else
echo " $ICON_INFO State file: not found (ramdisk never started this boot)"
fi
if pgrep -f "transcode_manager.sh" >/dev/null 2>&1; then
echo ""
warn "transcode_manager.sh is currently RUNNING"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Preflight ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Preflight ━━━"
# Bail if not mounted — nothing to do
if ! mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
warn "Ramdisk is not mounted at $RAMDISK_PATH — nothing to stop"
exit 0
fi
log "Ramdisk is mounted ✅"
# Warn if transcode_manager is running — it may flip symlink back on next cycle
if pgrep -f "transcode_manager.sh" >/dev/null 2>&1; then
warn "transcode_manager.sh is currently RUNNING"
warn "It may flip the symlink back to ramdisk on its next cycle"
warn "Stop transcode_manager.sh first if you need the SSD redirect to hold"
echo ""
fi
# Confirm SSD fallback exists
if [[ ! -d "$TRANSCODE_SSD" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — SSD fallback does not exist: $TRANSCODE_SSD"
warn "DRY RUN — would create it before redirecting symlink"
else
warn "SSD fallback does not exist — creating: $TRANSCODE_SSD"
mkdir -p "$TRANSCODE_SSD" || {
error "Failed to create SSD fallback: $TRANSCODE_SSD"
error "Cannot safely redirect symlink — aborting"
exit 1
}
log "SSD fallback created ✅"
fi
else
log "SSD fallback exists: $TRANSCODE_SSD ✅"
fi
START=$(date +%s)
STOP_SUCCESS=true
# ==============================================================================================
# ━━━ Redirect Symlink → SSD ━━━
# ==============================================================================================
# Redirect BEFORE unmount — Emby continues writing to SSD with no broken path window.
echo ""
echo "━━━ $ICON_LINK Redirect Symlink → SSD ━━━"
if [[ -L "$TRANSCODE_LINK" ]]; then
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK")
if [[ "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then
echo "Symlink already points to SSD — no change needed ✅"
else
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would redirect: $TRANSCODE_LINK → $TRANSCODE_SSD"
else
ln -sfn "$TRANSCODE_SSD" "$TRANSCODE_LINK" && \
warn "Symlink redirected: $TRANSCODE_LINK → $TRANSCODE_SSD ✅" || {
error "Failed to redirect symlink"
STOP_SUCCESS=false
}
fi
fi
elif [[ -e "$TRANSCODE_LINK" ]]; then
warn "$TRANSCODE_LINK exists but is not a symlink — leaving as-is"
else
warn "Symlink $TRANSCODE_LINK does not exist — nothing to redirect"
fi
# ==============================================================================================
# ━━━ Active Files Warning ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_RAM Active Files Check ━━━"
FILE_COUNT=$(find "$RAMDISK_PATH" -type f 2>/dev/null | wc -l)
if [[ "$FILE_COUNT" -gt 0 ]]; then
warn "⚠️ $FILE_COUNT file(s) still on ramdisk — will be lost on unmount"
warn "Active transcode sessions should be stopped before unmounting"
warn "Proceeding regardless (this is expected for maintenance)"
if [[ "$LOG" == true ]]; then
find "$RAMDISK_PATH" -type f 2>/dev/null | while read -r f; do
log " $f"
done
fi
else
log "No active files on ramdisk ✅"
fi
# ==============================================================================================
# ━━━ Unmount ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_RAM Unmount Ramdisk ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would unmount: $RAMDISK_PATH"
else
if umount "$RAMDISK_PATH" 2>/dev/null; then
warn "Ramdisk unmounted: $RAMDISK_PATH ✅"
else
# Regular unmount failed — check if only directory handles are open (no active writes)
OPEN_FILES=$(lsof +D "$RAMDISK_PATH" 2>/dev/null | awk 'NR>1 && $5 != "DIR"' | wc -l)
if [[ "$OPEN_FILES" -eq 0 ]]; then
warn "Busy — only directory handles open, no active writes — trying lazy unmount"
if umount -l "$RAMDISK_PATH"; then
warn "Ramdisk lazy-unmounted: $RAMDISK_PATH ✅"
warn "Handles will release when owning processes next check the directory"
else
error "Lazy unmount also failed — $RAMDISK_PATH"
STOP_SUCCESS=false
fi
else
error "Failed to unmount $RAMDISK_PATH — $OPEN_FILES file(s) still open for writing"
error "Stop active transcode sessions and retry"
STOP_SUCCESS=false
fi
fi
fi
# ==============================================================================================
# ━━━ Update State File ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR State File ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would update $STATE_FILE: current_target=$TRANSCODE_SSD"
elif [[ "$STOP_SUCCESS" == true ]]; then
NOW=$(date +%s)
cat > "$STATE_FILE" <<EOF
current_target=$TRANSCODE_SSD
last_flip_time=$NOW
flip_count_hour=0
flip_hour_start=$NOW
EOF
log "State file updated: current_target=$TRANSCODE_SSD"
else
warn "Skipping state file update — stop had errors"
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RAMDISK STOP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH"
echo "$ICON_DISK Fallback: $TRANSCODE_SSD"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK → $(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "not set")"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$STOP_SUCCESS" == true ]]; then
echo "$ICON_DONE Status: done ✅"
echo "Run ramdisk_setup.sh to remount with new configuration"
else
echo "$ICON_ERROR Status: STOP HAD ERRORS"
notify "Ramdisk stop errors on $(hostname) ($MY_ID) — check output" \
"Ramdisk Stop" "warning"
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,676 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Onboard ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs once on both servers to establish a new partnership. Role is detected
# automatically via detect_hosts() — no flags needed to declare which side you are.
# Run on the mirror first (generates its SSH key), then on the owner to complete
# setup remotely.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# MIRROR PATH (1 step)
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
# Owner completes the rest remotely. Mirror is done.
#
# OWNER PATH (10 steps)
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
# Step 4: Stop mirror arr — stop mirror's existing arr containers before replacing
# Step 5: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
# Step 6: Stop mirror services — stop mirror's existing services containers before replacing
# Step 7: Deploy services stack — push Emby/Jellyfin/Seerr XMLs, pull images, create + start
# Step 8: Partnership onboard — configure WebUIs → owner IP, write state, Emby
# Step 9: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 10: Conf push — push master.conf + setup state to all listed hosts
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials never in SSH command strings
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
# locally, SCPed to the remote, and executed there. Command-line args are never used
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
#
# XML templates are the single source of truth for deployed containers
# The owner's templates-user/ XMLs define every container deployed on the mirror.
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
# Docker Manager can manage the containers after onboard without additional config.
#
# Dependency ordering in the auth stack is owner-enforced
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
# the script waits for the container to be healthy before continuing. This is a remote
# health check — the container must be running (or report healthy) before the next
# dependent is deployed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check
# All operations run as root — SSH key management, docker operations, conf updates.
#
# SSH timeout on all remote calls
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
# slow or unreachable mirror.
#
# --dry-run shows exact actions without executing
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
# all dry-run safe.
#
# Step skip flags for partial re-runs
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
# resuming after a partial failure without re-running completed steps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# XML filenames (from this server's templates-user/) to push and deploy on the
# mirror as its auth stack. Order matters: database deps before Authelia.
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
#
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
# Containers to stop on the mirror before deploying the auth stack.
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_ARR_STACK
# XML filenames to push and deploy on the mirror as its arr stack.
# Leave empty to skip arr stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
#
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
# Arr containers to stop on the mirror before deploying the arr stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_SERVICES_STACK
# XML filenames to push and deploy on the mirror as its shared services stack.
# Includes Emby, Jellyfin, Seerr, SeerrFin. Leave empty to skip services stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_STACK
#
# HOST*_PARTNERSHIP_SERVICES_REPLACE_CONTAINERS
# Services containers to stop on the mirror before deploying the services stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_REPLACE_CONTAINERS (on the mirror)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_onboard.sh
# Full onboard — role detected automatically
#
# Partnership/partnership_onboard.sh --dry-run
# Preview all steps without making changes
#
# Partnership/partnership_onboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_onboard.sh --skip-ssh
# Skip SSH key setup (key already in place)
#
# Partnership/partnership_onboard.sh --skip-auth-stack
# Skip auth stack stop + deploy (Steps 3-4)
#
# Partnership/partnership_onboard.sh --skip-arr-stack
# Skip arr stack stop + deploy (Steps 4-5)
#
# Partnership/partnership_onboard.sh --skip-services-stack
# Skip services stack stop + deploy (Steps 6-7)
#
# Partnership/partnership_onboard.sh --skip-arr-sync
# Skip arr library bootstrap (Step 9)
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
SKIP_AUTH_STACK=false
SKIP_ARR_STACK=false
SKIP_SERVICES_STACK=false
SKIP_ARR_SYNC=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--skip-ssh) SKIP_SSH=true ;;
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
--skip-arr-stack) SKIP_ARR_STACK=true ;;
--skip-services-stack) SKIP_SERVICES_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
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.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
write_onboard_phase() {
local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE"
local state_file="$(platform_setup_db_path)"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
platform_push_setup_state
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
# use the same convention), and reads the named config array from the mirror's own conf.
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
#
# deploy_container_from_xml() already stops/removes containers with the same name as what's
# being deployed. This step handles containers with DIFFERENT names that conflict.
# ==============================================================================================
stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
return 0
fi
log "Stopping $label on $MIRROR: ${to_stop[*]}"
for container in "${to_stop[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
log " $container removed ✅" || \
log " $container not found on $MIRROR — skipping"
done
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
echo ""
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH key ready ✅"
else
error "SSH key setup failed"
exit 1
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
echo ""
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
PHASE2_TRIGGERED=false
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR via platform probe command — don't assume same path as mirror
_probe_cmd=$(platform_scripts_dir_probe_cmd)
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"$_probe_cmd" 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-$SCRIPTS_DIR}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
log "Phase 2 triggered on $OWNER ✅"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
PHASE2_TRIGGERED=true
else
warn "Could not auto-trigger Phase 2 on $OWNER"
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
if [[ "$PHASE2_TRIGGERED" == false ]]; then
echo ""
echo " Run manually on $OWNER:"
echo " bash Partnership/partnership_onboard.sh --phase2-only"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
echo ""
STEP_SSH_OK=false
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
AUTH_FAILED=0
STEP_STOP_ARR_OK=true
STEP_ARR_OK=true
ARR_DEPLOYED=0
ARR_FAILED=0
STEP_STOP_SERVICES_OK=true
STEP_SERVICES_OK=true
SERVICES_DEPLOYED=0
SERVICES_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
MASTER_PUSH_OK=false
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
# Skipped when --phase2-only (SSH was already done in Phase 1).
echo "━━━ Step 1 — SSH Key Setup ━━━"
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping (--skip-ssh)"
STEP_SSH_OK=true
elif [[ "$PHASE1_ONLY" == true ]]; then
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
# hanging for a password prompt with no TTY.
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
log "SSH to $MIRROR already works ✅ — skipping key install"
STEP_SSH_OK=true
else
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
# Soft-fail: generate key locally if not present, then tell user to install manually
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
if [[ -f "$SSH_KEY" ]]; then
log "Local key exists at: $SSH_KEY"
else
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
fi
if [[ -f "${SSH_KEY}.pub" ]]; then
echo ""
echo " Install this key on $MIRROR to complete SSH setup:"
echo " ┌─────────────────────────────────────────────────────"
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
echo " └─────────────────────────────────────────────────────"
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="$(platform_setup_db_path)"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
}
fi
STEP_SSH_OK=false
fi
fi
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
error "SSH key setup failed — aborting"
error "Re-run or use --skip-ssh if key is already set up"
exit 1
fi
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
if [[ "$PHASE1_ONLY" == true ]]; then
if [[ "$STEP_SSH_OK" == false ]]; then
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in Partnership tab, or run:"
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Phase 1 — Conf Push ━━━"
CONF_PUSH_OK=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf + state file to $MIRROR"
CONF_PUSH_OK=true
else
push_output=$(platform_push_conf)
push_rc=$?
[[ -n "$push_output" ]] && echo "$push_output"
platform_push_setup_state
if [[ $push_rc -eq 0 ]]; then
log "Conf push complete ✅"
CONF_PUSH_OK=true
else
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
else
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 3 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
STEP_AUTH_OK=false
else
deploy_xml_stack PARTNERSHIP_AUTH_STACK
AUTH_DEPLOYED=$_STACK_DEPLOYED
AUTH_FAILED=$_STACK_FAILED
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
SKIP_ARR_STACK=true
else
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
fi
# ── Step 5: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
else
deploy_xml_stack PARTNERSHIP_ARR_STACK
ARR_DEPLOYED=$_STACK_DEPLOYED
ARR_FAILED=$_STACK_FAILED
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
fi
# ── Step 6: Stop mirror's existing services stack ─────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Stop Mirror Services Stack ━━━"
if [[ "$SKIP_SERVICES_STACK" == true ]]; then
warn "Skipping (--skip-services-stack)"
elif [[ ${#PARTNERSHIP_SERVICES_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_SERVICES_STACK not configured — skipping services stack deploy"
SKIP_SERVICES_STACK=true
else
stop_mirror_stack "PARTNERSHIP_SERVICES_REPLACE_CONTAINERS" "services stack"
fi
# ── Step 7: Deploy services stack on mirror ───────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Deploy Services Stack on Mirror ━━━"
if [[ "$SKIP_SERVICES_STACK" == true ]]; then
warn "Skipping (--skip-services-stack)"
else
deploy_xml_stack PARTNERSHIP_SERVICES_STACK
SERVICES_DEPLOYED=$_STACK_DEPLOYED
SERVICES_FAILED=$_STACK_FAILED
echo "Services stack: $SERVICES_DEPLOYED deployed, $SERVICES_FAILED failed"
[[ "$SERVICES_FAILED" -gt 0 ]] && STEP_SERVICES_OK=false
fi
# ── Step 8: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 8 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
ONBOARD_OK=true
else
error "Partnership onboard failed"
ONBOARD_OK=false
fi
# ── Step 9: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 9 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
fi
# ── Step 10: Push master.conf to all listed hosts ─────────────────────────────────────────────
# SSH is now established and all partners have the plugin installed.
# Push the authoritative master.conf so every listed host is in sync immediately.
echo ""
echo "━━━ $ICON_GEAR Step 10 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf to all listed hosts"
MASTER_PUSH_OK=true
else
push_output=$(platform_push_conf)
push_rc=$?
[[ -n "$push_output" ]] && echo "$push_output"
platform_push_setup_state
if [[ $push_rc -eq 0 ]]; then
echo "master.conf sync complete ✅"
MASTER_PUSH_OK=true
else
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 2 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 3 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 4 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
echo " Step 5 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
echo " Step 6 — Stop services: $(_skip "$SKIP_SERVICES_STACK" "$STEP_STOP_SERVICES_OK")"
echo " Step 7 — Services stack: $( [[ "$SKIP_SERVICES_STACK" == true ]] && echo "skipped" || echo "${SERVICES_DEPLOYED} deployed, ${SERVICES_FAILED} failed" )"
echo " Step 8 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 9 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
echo "$ICON_DONE DONE — partnership established ✅"
echo "Verify with: Partnership/partnership_manager.sh --status"
else
error "Setup incomplete — resolve errors above and re-run"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ONBOARD_OK" == false ]] && exit 1
exit 0
@@ -0,0 +1,697 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Onboard ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Runs once on both servers to establish a new partnership. Role is detected
# automatically via detect_hosts() — no flags needed to declare which side you are.
# Run on the mirror first (generates its SSH key), then on the owner to complete
# setup remotely.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# MIRROR PATH (1 step)
# Step 1: SSH key setup — generate keypair, copy to owner, update conf
# Owner completes the rest remotely. Mirror is done.
#
# OWNER PATH (10 steps)
# Step 1: SSH key setup — generate keypair, install on mirror, update conf
# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing
# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror
# Mariadb/Redis health-checked before Authelia deploys
# Step 4: Stop mirror arr — stop mirror's existing arr containers before replacing
# Step 5: Deploy arr stack — push arr XMLs, pull images, create + start on mirror
# Step 6: Stop mirror services — stop mirror's existing services containers before replacing
# Step 7: Deploy services stack — push Emby/Jellyfin/Seerr XMLs, pull images, create + start
# Step 8: Partnership onboard — configure WebUIs → owner IP, write state, Emby
# Step 9: Arr bootstrap — bidirectional library sync (arr_sync.sh)
# Step 10: Conf push — push master.conf + setup state to all listed hosts
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials never in SSH command strings
# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written
# locally, SCPed to the remote, and executed there. Command-line args are never used
# to pass credentials — they'd appear in `ps` output and shell history on both servers.
#
# XML templates are the single source of truth for deployed containers
# The owner's templates-user/ XMLs define every container deployed on the mirror.
# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's
# Docker Manager can manage the containers after onboard without additional config.
#
# Dependency ordering in the auth stack is owner-enforced
# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.
# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,
# the script waits for the container to be healthy before continuing. This is a remote
# health check — the container must be running (or report healthy) before the next
# dependent is deployed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check
# All operations run as root — SSH key management, docker operations, conf updates.
#
# SSH timeout on all remote calls
# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a
# slow or unreachable mirror.
#
# --dry-run shows exact actions without executing
# Every step prints what it would do. SCP, deploy, plugin install, arr sync —
# all dry-run safe.
#
# Step skip flags for partial re-runs
# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow
# resuming after a partial failure without re-running completed steps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# XML filenames (from this server's templates-user/) to push and deploy on the
# mirror as its auth stack. Order matters: database deps before Authelia.
# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK
#
# HOST*_PARTNERSHIP_REPLACE_CONTAINERS
# Containers to stop on the mirror before deploying the auth stack.
# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.
# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at
# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).
# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).
# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_ARR_STACK
# XML filenames to push and deploy on the mirror as its arr stack.
# Leave empty to skip arr stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK
#
# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS
# Arr containers to stop on the mirror before deploying the arr stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)
#
# HOST*_PARTNERSHIP_SERVICES_STACK
# XML filenames to push and deploy on the mirror as its shared services stack.
# Includes Emby, Jellyfin, Seerr, SeerrFin. Leave empty to skip services stack deploy.
# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_STACK
#
# HOST*_PARTNERSHIP_SERVICES_REPLACE_CONTAINERS
# Services containers to stop on the mirror before deploying the services stack.
# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.
# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_REPLACE_CONTAINERS (on the mirror)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_onboard.sh
# Full onboard — role detected automatically
#
# Partnership/partnership_onboard.sh --dry-run
# Preview all steps without making changes
#
# Partnership/partnership_onboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_onboard.sh --skip-ssh
# Skip SSH key setup (key already in place)
#
# Partnership/partnership_onboard.sh --skip-auth-stack
# Skip auth stack stop + deploy (Steps 3-4)
#
# Partnership/partnership_onboard.sh --skip-arr-stack
# Skip arr stack stop + deploy (Steps 4-5)
#
# Partnership/partnership_onboard.sh --skip-services-stack
# Skip services stack stop + deploy (Steps 6-7)
#
# Partnership/partnership_onboard.sh --skip-arr-sync
# Skip arr library bootstrap (Step 9)
#
# Partnership/partnership_onboard.sh --phase1-only
# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.
# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.
#
# Partnership/partnership_onboard.sh --phase2-only
# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically
# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.
# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
SKIP_SSH=false
SKIP_AUTH_STACK=false
SKIP_ARR_STACK=false
SKIP_SERVICES_STACK=false
SKIP_ARR_SYNC=false
PHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)
PHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--skip-ssh) SKIP_SSH=true ;;
--skip-auth-stack) SKIP_AUTH_STACK=true ;;
--skip-arr-stack) SKIP_ARR_STACK=true ;;
--skip-services-stack) SKIP_SERVICES_STACK=true ;;
--skip-arr-sync) SKIP_ARR_SYNC=true ;;
--phase1-only) PHASE1_ONLY=true ;;
--phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
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.
# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's
# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.
MIRROR_SSH_KEY="$SSH_KEY"
AM_OWNER=false
AM_MIRROR=false
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
EXTRA_FLAGS=()
[[ "$DRY_RUN" == true ]] && EXTRA_FLAGS+=("--dry-run")
[[ "$LOG_MODE" == true ]] && EXTRA_FLAGS+=("--log")
START=$(date +%s)
# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────
write_onboard_phase() {
local target_id="$1" phase="$2"
local key="${target_id}_PHASE${phase}_DONE"
local state_file="$(platform_setup_db_path)"
[[ "$DRY_RUN" == true ]] && { warn "DRY RUN — would write ${key}=true"; return 0; }
if grep -q "^${key}=" "$state_file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=true|" "$state_file"
else
echo "${key}=true" >> "$state_file"
fi
platform_push_setup_state
}
echo ""
echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#
# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers
# use the same convention), and reads the named config array from the mirror's own conf.
# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.
# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).
#
# deploy_container_from_xml() already stops/removes containers with the same name as what's
# being deployed. This step handles containers with DIFFERENT names that conflict.
# ==============================================================================================
stop_mirror_stack() {
local config_var="$1" label="$2"
local -a to_stop=()
mapfile -t to_stop < <(
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${config_var}[@]:-}\"" 2>/dev/null | grep -v '^$'
)
if [[ ${#to_stop[@]} -eq 0 ]]; then
log "No $label containers to stop on $MIRROR — skipping"
return 0
fi
log "Stopping $label on $MIRROR: ${to_stop[*]}"
for container in "${to_stop[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn " DRY RUN — would stop + rm $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
"docker stop '$container' 2>/dev/null
docker rm '$container' 2>/dev/null && echo removed" 2>/dev/null | \
grep -q removed && \
log " $container removed ✅" || \
log " $container not found on $MIRROR — skipping"
done
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
echo "━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━"
echo ""
echo " Mirror sets up SSH keys, then notifies Owner to run Phase 2."
echo ""
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping SSH setup (--skip-ssh)"
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH key ready ✅"
else
error "SSH key setup failed"
exit 1
fi
echo ""
echo "━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━"
echo ""
OWNER_IP=$(resolve_tailscale_ip "$OWNER" 2>/dev/null || true)
PHASE2_TRIGGERED=false
if [[ -n "$OWNER_IP" ]]; then
# Read OWNER's SCRIPTS_DIR via platform probe command — don't assume same path as mirror
_probe_cmd=$(platform_scripts_dir_probe_cmd)
OWNER_SCRIPTS_DIR=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"$_probe_cmd" 2>/dev/null | tr -d '[:space:]')
OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-$SCRIPTS_DIR}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2"
PHASE2_TRIGGERED=true
elif timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$OWNER_IP" \
"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered" \
2>/dev/null | grep -q triggered; then
log "Phase 2 triggered on $OWNER ✅"
log "Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log"
PHASE2_TRIGGERED=true
else
warn "Could not auto-trigger Phase 2 on $OWNER"
fi
else
warn "Cannot resolve $OWNER Tailscale IP"
fi
echo ""
echo "━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━"
echo " SSH key: ready"
echo " Phase 2 on $OWNER: $( [[ "$PHASE2_TRIGGERED" == true ]] && echo "triggered ✅" || echo "needs manual trigger ⚠" )"
if [[ "$PHASE2_TRIGGERED" == false ]]; then
echo ""
echo " Run manually on $OWNER:"
echo " bash Partnership/partnership_onboard.sh --phase2-only"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP — is Tailscale running?"; exit 1; }
log "Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE1_ONLY" == true ]] && log "Mode: Phase 1 only (SSH + conf push)"
[[ "$PHASE2_ONLY" == true ]] && log "Mode: Phase 2 only (containers + arr + onboard)"
echo ""
STEP_SSH_OK=false
STEP_NETWORK_OK=false
STEP_STOP_AUTH_OK=true
STEP_AUTH_OK=true
AUTH_DEPLOYED=0
AUTH_FAILED=0
STEP_STOP_ARR_OK=true
STEP_ARR_OK=true
ARR_DEPLOYED=0
ARR_FAILED=0
STEP_STOP_SERVICES_OK=true
STEP_SERVICES_OK=true
SERVICES_DEPLOYED=0
SERVICES_FAILED=0
ONBOARD_OK=false
ARR_SYNC_OK=false
MASTER_PUSH_OK=false
# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────
# Skipped when --phase2-only (SSH was already done in Phase 1).
echo "━━━ Step 1 — SSH Key Setup ━━━"
if [[ "$SKIP_SSH" == true ]]; then
warn "Skipping (--skip-ssh)"
STEP_SSH_OK=true
elif [[ "$PHASE1_ONLY" == true ]]; then
# Phase 1 in background: test if SSH already works first — avoids ssh-copy-id
# hanging for a password prompt with no TTY.
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" exit 0 2>/dev/null; then
log "SSH to $MIRROR already works ✅ — skipping key install"
STEP_SSH_OK=true
else
# Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)
if bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
# Soft-fail: generate key locally if not present, then tell user to install manually
warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
if [[ -f "$SSH_KEY" ]]; then
log "Local key exists at: $SSH_KEY"
else
bash "$SCRIPT_DIR/ssh_setup.sh" --key-only "${EXTRA_FLAGS[@]}" 2>/dev/null || true
fi
if [[ -f "${SSH_KEY}.pub" ]]; then
echo ""
echo " Install this key on $MIRROR to complete SSH setup:"
echo " ┌─────────────────────────────────────────────────────"
cat "${SSH_KEY}.pub" | sed 's/^/ │ /'
echo " └─────────────────────────────────────────────────────"
echo " Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in the Partnership tab."
# Write key-ready flag so UI can show the manual-install state
[[ "$DRY_RUN" == false ]] && {
local kflag="${MIRROR_ID}_KEY_READY"
local _setup_f="$(platform_setup_db_path)"
grep -q "^${kflag}=" "$_setup_f" 2>/dev/null \
&& sed -i "s|^${kflag}=.*|${kflag}=true|" "$_setup_f" \
|| echo "${kflag}=true" >> "$_setup_f"
}
fi
STEP_SSH_OK=false
fi
fi
elif bash "$SCRIPT_DIR/ssh_setup.sh" "${EXTRA_FLAGS[@]}"; then
log "SSH keys ready ✅"
STEP_SSH_OK=true
else
error "SSH key setup failed — aborting"
error "Re-run or use --skip-ssh if key is already set up"
exit 1
fi
# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────
# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.
if [[ "$PHASE1_ONLY" == true ]]; then
if [[ "$STEP_SSH_OK" == false ]]; then
# SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
# UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━"
echo " SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠"
echo " Conf push: skipped (needs SSH access to $MIRROR)"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " ACTION NEEDED: install the key on $MIRROR:"
echo " ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}"
echo " Then click 'Push Conf' in Partnership tab, or run:"
echo " bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
echo ""
echo "━━━ Phase 1 — Conf Push ━━━"
CONF_PUSH_OK=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf + state file to $MIRROR"
CONF_PUSH_OK=true
else
push_output=$(platform_push_conf)
push_rc=$?
[[ -n "$push_output" ]] && echo "$push_output"
platform_push_setup_state
if [[ $push_rc -eq 0 ]]; then
log "Conf push complete ✅"
CONF_PUSH_OK=true
else
warn "Conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# HOST1 local setup — runs immediately without needing HOST2
echo ""
echo "━━━ Phase 1 — HOST1 Local Setup ━━━"
bash "$SCRIPT_DIR/partnership_manager.sh" --onboard --local-only "${EXTRA_FLAGS[@]}" || \
warn "Local setup had issues — check partnership_manager.sh output above"
[[ "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 1
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━"
echo " SSH keys: $( [[ "$STEP_SSH_OK" == true ]] && echo "ready ✅" || echo "skipped" )"
echo " Conf push: $( [[ "$CONF_PUSH_OK" == true ]] && echo "done ✅" || echo "⚠ manual needed" )"
echo " HOST1 setup: done ✅"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
echo " HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
echo " The wizard will detect the pushed conf and take the correct path."
echo " When HOST2 completes its onboard, it will automatically trigger Phase 2 here."
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Step 1b: Ensure custom Docker network exists on mirror ────────────────────────────────────
# Must run before any container deploy — docker create fails if the network is missing.
echo ""
echo "━━━ Step 1b — Docker Network (Mirror) ━━━"
_net_script="${SCRIPTS_ROOT}/Docker_Essentials/docker_network_connect.sh"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would run docker_network_connect.sh on $MIRROR"
STEP_NETWORK_OK=true
elif timeout 60 ssh -i "$MIRROR_SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
"bash '$_net_script'" 2>/dev/null; then
log "Docker network ready on $MIRROR ✅"
STEP_NETWORK_OK=true
else
warn "docker_network_connect.sh failed on $MIRROR — containers may fail if network is missing"
warn "Check ${_net_script} on $MIRROR and re-run with --skip-ssh if needed"
fi
# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Stop Mirror Auth Stack ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
else
stop_mirror_stack "PARTNERSHIP_REPLACE_CONTAINERS" "auth stack"
fi
# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 3 — Deploy Auth Stack on Mirror ━━━"
if [[ "$SKIP_AUTH_STACK" == true ]]; then
warn "Skipping (--skip-auth-stack)"
elif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then
warn "PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy"
warn "Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf"
STEP_AUTH_OK=false
else
deploy_xml_stack PARTNERSHIP_AUTH_STACK
AUTH_DEPLOYED=$_STACK_DEPLOYED
AUTH_FAILED=$_STACK_FAILED
echo "Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed"
[[ "$AUTH_FAILED" -gt 0 ]] && STEP_AUTH_OK=false
fi
# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────
echo ""
echo "━━━ Step 4 — Stop Mirror Arr Stack ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
elif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy"
SKIP_ARR_STACK=true
else
stop_mirror_stack "PARTNERSHIP_ARR_REPLACE_CONTAINERS" "arr stack"
fi
# ── Step 5: Deploy arr stack on mirror ───────────────────────────────────────────────────────
echo ""
echo "━━━ Step 5 — Deploy Arr Stack on Mirror ━━━"
if [[ "$SKIP_ARR_STACK" == true ]]; then
warn "Skipping (--skip-arr-stack)"
else
deploy_xml_stack PARTNERSHIP_ARR_STACK
ARR_DEPLOYED=$_STACK_DEPLOYED
ARR_FAILED=$_STACK_FAILED
echo "Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed"
[[ "$ARR_FAILED" -gt 0 ]] && STEP_ARR_OK=false
fi
# ── Step 6: Stop mirror's existing services stack ─────────────────────────────────────────────
echo ""
echo "━━━ Step 6 — Stop Mirror Services Stack ━━━"
if [[ "$SKIP_SERVICES_STACK" == true ]]; then
warn "Skipping (--skip-services-stack)"
elif [[ ${#PARTNERSHIP_SERVICES_STACK[@]} -eq 0 ]]; then
log "PARTNERSHIP_SERVICES_STACK not configured — skipping services stack deploy"
SKIP_SERVICES_STACK=true
else
stop_mirror_stack "PARTNERSHIP_SERVICES_REPLACE_CONTAINERS" "services stack"
fi
# ── Step 7: Deploy services stack on mirror ───────────────────────────────────────────────────
echo ""
echo "━━━ Step 7 — Deploy Services Stack on Mirror ━━━"
if [[ "$SKIP_SERVICES_STACK" == true ]]; then
warn "Skipping (--skip-services-stack)"
else
deploy_xml_stack PARTNERSHIP_SERVICES_STACK
SERVICES_DEPLOYED=$_STACK_DEPLOYED
SERVICES_FAILED=$_STACK_FAILED
echo "Services stack: $SERVICES_DEPLOYED deployed, $SERVICES_FAILED failed"
[[ "$SERVICES_FAILED" -gt 0 ]] && STEP_SERVICES_OK=false
fi
# ── Step 8: Partnership onboard ───────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 8 — Partnership Onboard ━━━"
if bash "$SCRIPTS_ROOT/Partnership/partnership_manager.sh" --onboard "${EXTRA_FLAGS[@]}"; then
echo "Partnership onboard complete ✅"
ONBOARD_OK=true
else
error "Partnership onboard failed"
ONBOARD_OK=false
fi
# ── Step 9: Arr library bootstrap ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 9 — Arr Library Bootstrap ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$SKIP_ARR_SYNC" == true ]]; then
warn "Skipping (--skip-arr-sync)"
elif [[ ! -f "$SCRIPTS_ROOT/Media/arr_sync.sh" ]]; then
warn "arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live"
elif bash "$SCRIPTS_ROOT/Media/arr_sync.sh" "${EXTRA_FLAGS[@]}"; then
echo "Arr bootstrap complete ✅"
ARR_SYNC_OK=true
else
warn "Arr sync had errors — partnership still valid"
warn "Re-run Media/arr_sync.sh once all arr containers are live"
fi
# ── Step 10: Push master.conf to all listed hosts ─────────────────────────────────────────────
# SSH is now established and all partners have the plugin installed.
# Push the authoritative master.conf so every listed host is in sync immediately.
echo ""
echo "━━━ $ICON_GEAR Step 10 — master.conf Push ━━━"
if [[ "$ONBOARD_OK" == false ]]; then
warn "Skipping — onboard did not complete"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push master.conf to all listed hosts"
MASTER_PUSH_OK=true
else
push_output=$(platform_push_conf)
push_rc=$?
[[ -n "$push_output" ]] && echo "$push_output"
platform_push_setup_state
if [[ $push_rc -eq 0 ]]; then
echo "master.conf sync complete ✅"
MASTER_PUSH_OK=true
else
warn "master.conf push had failures — retry via Scheduler → master.conf → Save Conf"
fi
fi
# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────
[[ "$ONBOARD_OK" == true && "$DRY_RUN" == false ]] && write_onboard_phase "$MIRROR_ID" 2
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR ($MIRROR_IP)"
[[ "$PHASE2_ONLY" == true ]] && echo " Mode: Phase 2 (triggered by HOST2 notification)"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
echo " Step 1 — SSH keys: $(_skip "$SKIP_SSH" "$STEP_SSH_OK")"
echo " Step 1b — Docker network: $(_ok "$STEP_NETWORK_OK")"
echo " Step 2 — Stop auth: $(_skip "$SKIP_AUTH_STACK" "$STEP_STOP_AUTH_OK")"
echo " Step 3 — Auth stack: $( [[ "$SKIP_AUTH_STACK" == true ]] && echo "skipped" || echo "${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed" )"
echo " Step 4 — Stop arr: $(_skip "$SKIP_ARR_STACK" "$STEP_STOP_ARR_OK")"
echo " Step 5 — Arr stack: $( [[ "$SKIP_ARR_STACK" == true ]] && echo "skipped" || echo "${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed" )"
echo " Step 6 — Stop services: $(_skip "$SKIP_SERVICES_STACK" "$STEP_STOP_SERVICES_OK")"
echo " Step 7 — Services stack: $( [[ "$SKIP_SERVICES_STACK" == true ]] && echo "skipped" || echo "${SERVICES_DEPLOYED} deployed, ${SERVICES_FAILED} failed" )"
echo " Step 8 — Onboard: $(_ok "$ONBOARD_OK")"
echo " Step 9 — Arr bootstrap: $( [[ "$SKIP_ARR_SYNC" == true || "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$ARR_SYNC_OK")" )"
echo " Step 10 — Conf push: $( [[ "$ONBOARD_OK" == false ]] && echo "skipped" || echo "$(_ok "$MASTER_PUSH_OK")" )"
echo ""
if [[ "$ONBOARD_OK" == true ]]; then
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
echo "$ICON_DONE DONE — partnership established ✅"
echo "Verify with: Partnership/partnership_manager.sh --status"
else
error "Setup incomplete — resolve errors above and re-run"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ONBOARD_OK" == false ]] && exit 1
exit 0
@@ -0,0 +1,158 @@
#!/bin/bash
# ==============================================================================================
# ============================= Conf Cache Sync ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Maintains a RAM-resident conf cache at /tmp/.vv/config/cached/.confs/.
# Credentials and partner keys live in RAM only — never on disk across hosts.
#
# On array start (default / --array-start):
# 1. Copy own conf to local cache
# 2. Pull each available partner's conf from their disk → local cache
# 3. Push own conf to each available partner's /tmp/.vv/ cache
#
# On conf save (--push-only):
# Fast path — push updated own conf to all partners' /tmp/.vv/ cache only.
# No pulls, no local cache rebuild.
#
# Cache is /tmp (tmpfs) — cleared every reboot, repopulated by this script
# on next array start. Scripts source from cache for partner vars; own vars
# always come from disk (load_config.sh skips cached copy of own conf).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# conf_sync.sh Full sync: pull from all partners + push to all partners
# conf_sync.sh --push-only Push own conf to all partners (fast, for conf-save hook)
# conf_sync.sh --pull-only Pull partner confs into local cache only (for intermediate orch)
# conf_sync.sh --dry-run Show what would happen, no changes
# conf_sync.sh --log Verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
PUSH_ONLY=false
PULL_ONLY=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--push-only) PUSH_ONLY=true ;;
--pull-only) PULL_ONLY=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
detect_hosts
if [[ "${CONF_SYNC_ENABLED:-true}" == false ]]; then
log "CONF_SYNC_ENABLED=false — skipping"
exit 0
fi
CACHE_DIR="/tmp/.vv/config/cached/.confs"
MY_CONF="$SCRIPTS_ROOT/Configurations/${MY_ID,,}.conf"
SSH_TIMEOUT=10
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ── Ensure cache dir exists ───────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$CACHE_DIR"
fi
# ── Copy own conf into local cache ───────────────────────────────────────────
if [[ "$PUSH_ONLY" == false ]] && [[ "$PULL_ONLY" == false ]]; then
if [[ -f "$MY_CONF" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would copy $(basename "$MY_CONF") → $CACHE_DIR/"
else
cp "$MY_CONF" "$CACHE_DIR/${MY_ID,,}.conf" && \
log "Own conf cached ✅" || warn "Failed to cache own conf"
fi
else
warn "Own conf not found: $MY_CONF"
fi
fi
# ── Per-partner sync ──────────────────────────────────────────────────────────
PUSHED=0
PULLED=0
FAILED=0
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$' | sort); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
if [[ -z "$partner_ip" ]]; then
warn "$partner_host — cannot resolve Tailscale IP, skipping"
(( FAILED++ ))
continue
fi
# ── Pull: grab partner's conf from their disk → our local cache ──────────
if [[ "$PUSH_ONLY" == false ]]; then
remote_conf="${SCRIPTS_DIR}/Configurations/${partner_slot}.conf"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull $partner_host:$remote_conf → $CACHE_DIR/${partner_slot}.conf"
elif timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}:${remote_conf}" \
"$CACHE_DIR/${partner_slot}.conf" 2>/dev/null; then
log "Pulled ${partner_slot}.conf from $partner_host ✅"
(( PULLED++ ))
else
warn "Could not pull ${partner_slot}.conf from $partner_host"
(( FAILED++ ))
fi
fi
# ── Push: send own conf to partner's /tmp/.vv/ cache ────────────────────
if [[ "$PULL_ONLY" == true ]]; then
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would push ${MY_ID,,}.conf → $partner_host:/tmp/.vv/config/cached/.confs/"
continue
fi
# Ensure partner's cache dir exists, then SCP own conf into it
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"root@${partner_ip}" "mkdir -p '$CACHE_DIR'" 2>/dev/null
if timeout "$SSH_TIMEOUT" scp -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes -o StrictHostKeyChecking=no \
"$MY_CONF" \
"root@${partner_ip}:${CACHE_DIR}/${MY_ID,,}.conf" 2>/dev/null; then
log "Pushed ${MY_ID,,}.conf to $partner_host ✅"
(( PUSHED++ ))
else
warn "Could not push to $partner_host"
(( FAILED++ ))
fi
done
# ── Summary ───────────────────────────────────────────────────────────────────
if [[ "$PUSH_ONLY" == true ]]; then
info "Conf push complete — pushed to $PUSHED host(s)${FAILED:+, $FAILED failed}"
elif [[ "$PULL_ONLY" == true ]]; then
info "Conf pull complete — pulled $PULLED partner conf(s)${FAILED:+, $FAILED failed}"
else
info "Conf sync complete — pulled $PULLED, pushed $PUSHED${FAILED:+, $FAILED failed}"
fi
if [[ "$FAILED" -gt 0 ]]; then
notify "Conf sync on $LOCAL_SERVER_NAME ($MY_ID) — $FAILED partner(s) failed. Partner config cache may be stale." \
"Conf Sync" "warning"
exit 1
fi
@@ -0,0 +1,365 @@
#!/bin/bash
# ==============================================================================================
# ================================= Ramdisk Setup ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates the tmpfs ramdisk, SSD fallback directory, transcode symlink, and
# pre-creates transcoding-temp on the ramdisk. Run once at array start via
# array_started.sh (System_Essentials/). Idempotent — already-mounted ramdisk
# reports status and exits cleanly. Always resets the symlink to the ramdisk
# on boot, ensuring a clean state regardless of what state it was in before
# shutdown.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Creates four things in order:
# 1. RAMDISK_PATH — tmpfs mount (size: HOST*_RAMDISK_SIZE ceiling, not a reservation)
# 2. TRANSCODE_SSD — SSD fallback directory and transcoding-temp inside it
# 3. TRANSCODE_LINK — symlink reset to RAMDISK_PATH (clean state at every boot)
# 4. transcoding-temp/ inside RAMDISK_PATH — pre-created before Emby starts
#
# The transcoding-temp pre-creation is critical: if it doesn't exist on the ramdisk
# when Emby starts, Emby searches all accessible paths for an existing one and finds
# the SSD fallback version — routing all sessions there until Emby restarts.
#
# Initialises /tmp/transcode_state.db with current target and flip counters.
# /tmp resets on reboot — correct, transcode state should not persist across boots.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# mount and symlink creation require root.
#
# Single Instance Lock
# acquire_lock prevents duplicate runs at array start.
#
# Idempotent Mount Check
# If RAMDISK_PATH is already a mountpoint, reports status and exits cleanly
# without attempting to remount or changing anything.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# Silent on Success
# Startup script runs on every boot — no output when healthy.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_RAMDISK_SIZE
# tmpfs ceiling (e.g. 10G). Must change together with WARN_GB and LOW_GB.
# Aliased by detect_hosts() → RAMDISK_SIZE.
#
# HOST*_RAMDISK_WARN_GB
# Usage level at which transcode_manager.sh flips symlink to SSD.
#
# HOST*_RAMDISK_LOW_GB
# Usage level at which transcode_manager.sh flips back to ramdisk.
#
# HOST*_TRANSCODE_SSD
# SSD fallback directory path.
# Aliased by detect_hosts() → TRANSCODE_SSD.
#
# master.conf
#
# TRANSCODE_LINK
# Symlink path Emby uses as its transcode directory. Must match the path
# configured in Emby's transcoding settings.
#
# TRANSCODE_CHMOD / TRANSCODE_OWNER
# Permissions applied to both ramdisk and SSD directories. (default: 755 / nobody:users)
#
# TRANSCODE_STATE_FILE
# Override state file path. (default: ${STATE_DIR}/transcode_state.db)
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# TRANSCODE_STATE_FILE (default: ${STATE_DIR}/transcode_state.db)
# Current symlink target + flip count tracking. Lives in STATE_DIR
# (ephemeral on Unraid — resets on reboot correctly).
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# ramdisk_setup.sh
# Normal setup run. Called by array_started.sh at boot.
#
# ramdisk_setup.sh --dry-run
# Show what would be created without creating anything.
#
# ramdisk_setup.sh --status
# Show current ramdisk mount state, symlink target, and SSD directory state.
#
# ramdisk_setup.sh --log
# Verbose output showing each creation step.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — mount and symlink require root"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID and aliases RAMDISK_SIZE, TRANSCODE_SSD etc.
detect_hosts
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ramdisk: $RAMDISK_PATH ($RAMDISK_SIZE)"
log "Fallback: $TRANSCODE_SSD"
log "Symlink: $TRANSCODE_LINK"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk path: $RAMDISK_PATH"
echo "$ICON_RAM Ramdisk size: $RAMDISK_SIZE"
echo "$ICON_RAM Warn at: ${RAMDISK_WARN_GB}GB"
echo "$ICON_RAM Flip at: ${RAMDISK_LOW_GB}GB"
echo "$ICON_DISK SSD fallback: $TRANSCODE_SSD"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK"
echo "$ICON_GEAR Owner: $TRANSCODE_OWNER"
echo "$ICON_GEAR Mode: $TRANSCODE_CHMOD"
echo ""
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅"
else
echo " $ICON_RAM Ramdisk: NOT mounted"
fi
if [[ -L "$TRANSCODE_LINK" ]]; then
TARGET=$(readlink "$TRANSCODE_LINK")
echo " $ICON_LINK Symlink: $TRANSCODE_LINK → $TARGET"
else
echo " $ICON_LINK Symlink: not set"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Ramdisk ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_RAM Ramdisk — $MY_ID ━━━"
log "$ICON_RAM Path: $RAMDISK_PATH"
log "$ICON_RAM Size: $RAMDISK_SIZE"
echo ""
START=$(date +%s)
SETUP_SUCCESS=true
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}')
AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}')
log "Ramdisk already mounted — $USAGE used / $AVAIL available"
log "Skipping mount — verifying symlink and permissions"
else
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create $RAMDISK_PATH"
warn "DRY RUN — would mount tmpfs ${RAMDISK_SIZE} at $RAMDISK_PATH"
else
log "Creating ramdisk mount point: $RAMDISK_PATH"
mkdir -p "$RAMDISK_PATH" || {
error "Failed to create $RAMDISK_PATH"
notify "Ramdisk setup failed on $(hostname) ($MY_ID) — could not create mount point" \
"Ramdisk Setup" "warning"
exit 1
}
log "Mounting tmpfs ${RAMDISK_SIZE} at $RAMDISK_PATH..."
if mount -t tmpfs -o size="$RAMDISK_SIZE" tmpfs "$RAMDISK_PATH"; then
warn "Ramdisk mounted — ${RAMDISK_SIZE} at $RAMDISK_PATH ✅"
else
error "Failed to mount ramdisk at $RAMDISK_PATH"
notify "Ramdisk setup failed on $(hostname) ($MY_ID) — mount failed" \
"Ramdisk Setup" "warning"
exit 1
fi
fi
fi
# ==============================================================================================
# ━━━ SSD Fallback ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_DISK SSD Fallback ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create SSD fallback: $TRANSCODE_SSD"
else
if [[ -d "$TRANSCODE_SSD" ]]; then
log "SSD fallback already exists: $TRANSCODE_SSD"
else
log "Creating SSD fallback directory: $TRANSCODE_SSD"
if mkdir -p "$TRANSCODE_SSD"; then
log "SSD fallback created: $TRANSCODE_SSD ✅"
else
error "Failed to create SSD fallback: $TRANSCODE_SSD"
SETUP_SUCCESS=false
fi
fi
fi
# ==============================================================================================
# ━━━ Symlink ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_LINK Symlink ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set $TRANSCODE_LINK → $RAMDISK_PATH"
else
if [[ -L "$TRANSCODE_LINK" ]]; then
CURRENT_TARGET=$(readlink "$TRANSCODE_LINK")
if [[ "$CURRENT_TARGET" == "$RAMDISK_PATH" ]]; then
log "Symlink already points to ramdisk — no change needed ✅"
else
log "Updating symlink: $CURRENT_TARGET → $RAMDISK_PATH"
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK" || {
error "Failed to update symlink"
SETUP_SUCCESS=false
}
fi
elif [[ -e "$TRANSCODE_LINK" ]]; then
warn "$TRANSCODE_LINK exists but is not a symlink — removing and replacing"
rm -rf "$TRANSCODE_LINK"
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK" || {
error "Failed to create symlink"
SETUP_SUCCESS=false
}
else
log "Creating symlink: $TRANSCODE_LINK → $RAMDISK_PATH"
mkdir -p "$(dirname "$TRANSCODE_LINK")"
ln -sfn "$RAMDISK_PATH" "$TRANSCODE_LINK" || {
error "Failed to create symlink"
SETUP_SUCCESS=false
}
fi
[[ "$SETUP_SUCCESS" == true ]] && log "Symlink: $TRANSCODE_LINK → $RAMDISK_PATH ✅"
fi
# ==============================================================================================
# ━━━ Transcoding-temp Directory ━━━
# ==============================================================================================
# Pre-created inside ramdisk so Emby always finds it there at session start.
# Without this Emby creates it at its own first-writable path — which may be
# SSD even when the symlink points at the ramdisk — locking all sessions onto SSD.
echo ""
echo "━━━ $ICON_GEAR Transcoding Temp Directory ━━━"
TRANSCODE_TEMP_DIR="${RAMDISK_PATH}/transcoding-temp"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create $TRANSCODE_TEMP_DIR"
else
if [[ -d "$TRANSCODE_TEMP_DIR" ]]; then
log "transcoding-temp already exists on ramdisk"
else
if mkdir -p "$TRANSCODE_TEMP_DIR"; then
log "Created transcoding-temp on ramdisk ✅"
else
error "Failed to create transcoding-temp on ramdisk"
SETUP_SUCCESS=false
fi
fi
if [[ -d "$TRANSCODE_TEMP_DIR" ]]; then
chmod "$TRANSCODE_CHMOD" "$TRANSCODE_TEMP_DIR"
chown "$TRANSCODE_OWNER" "$TRANSCODE_TEMP_DIR"
log "Permissions set on transcoding-temp ($TRANSCODE_CHMOD $TRANSCODE_OWNER)"
fi
fi
# ==============================================================================================
# ━━━ Permissions ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Permissions ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would apply $TRANSCODE_CHMOD $TRANSCODE_OWNER to $RAMDISK_PATH and $TRANSCODE_SSD"
else
for path in "$RAMDISK_PATH" "$TRANSCODE_SSD"; do
if [[ -d "$path" ]]; then
chmod "$TRANSCODE_CHMOD" "$path"
chown "$TRANSCODE_OWNER" "$path"
log "Permissions set: $path ($TRANSCODE_CHMOD $TRANSCODE_OWNER)"
fi
done
fi
# ==============================================================================================
# ━━━ Initialise State File ━━━
# ==============================================================================================
if [[ "$DRY_RUN" == false ]]; then
STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}"
NOW=$(date +%s)
cat > "$STATE_FILE" <<EOF
current_target=$RAMDISK_PATH
last_flip_time=$NOW
flip_count_hour=0
flip_hour_start=$NOW
EOF
log "State file initialised: $STATE_FILE"
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RAMDISK SETUP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RAM Ramdisk: $RAMDISK_PATH ($RAMDISK_SIZE)"
echo "$ICON_DISK Fallback: $TRANSCODE_SSD"
echo "$ICON_LINK Symlink: $TRANSCODE_LINK"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$SETUP_SUCCESS" == true ]]; then
echo "$ICON_DONE Status: done ✅"
else
echo "$ICON_ERROR Status: SETUP HAD ERRORS"
notify "Ramdisk setup errors on $(hostname) ($MY_ID) — check output" \
"Ramdisk Setup" "warning"
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,166 @@
#!/bin/bash
# ==============================================================================================
# ================================= Unraid API Key Renewal ====================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates/overwrites the Varaverk API key in the unraid-api service registry at
# array start. The registry is ephemeral — OS updates and service restarts clear
# it. This script re-registers the key every boot so Varaverk's enhanced
# monitoring self-heals without manual intervention.
#
# Also updates HOST*_UNRAID_API_KEY in the local host conf so the partnership
# page always reflects the live key value.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# unraid_api_key_renew.sh
# Renew the key. Silent on success.
#
# unraid_api_key_renew.sh --dry-run
# Show what would happen — no changes made.
#
# unraid_api_key_renew.sh --log
# Verbose output.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
acquire_lock
detect_hosts
# ──────────────────────────────────────────────────────────────────────────────
CONF_FILE="$SCRIPT_DIR/../../../Configurations/${MY_ID,,}.conf"
VAR_NAME="${MY_ID}_UNRAID_API_KEY"
# Key name: "Varaverk <hostname>" stripping any unraid- prefix
# Space separator — unRAID API only allows letters, numbers, and spaces
HOSTNAME_SUFFIX=$(hostname -s 2>/dev/null | sed 's/^[Uu][Nn][Rr][Aa][Ii][Dd]-//' || hostname -s)
KEY_NAME="Varaverk ${HOSTNAME_SUFFIX}"
log "$ICON_GEAR Conf file: $CONF_FILE"
log "$ICON_GEAR Key var: $VAR_NAME"
log "$ICON_GEAR Key name: $KEY_NAME"
if [[ ! -f "$CONF_FILE" ]]; then
error "Conf file not found: $CONF_FILE"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would check registry for $KEY_NAME, renew only if missing"
exit 0
fi
# ──────────────────────────────────────────────────────────────────────────────
# Check if key already exists in the unraid-api registry before creating.
# --overwrite generates a new key value every time, invalidating the old one.
# Only renew if the registry has lost it.
log "Checking unraid-api registry for $KEY_NAME..."
EXISTING=$(timeout 5 /usr/local/sbin/unraid-api apikey --name "$KEY_NAME" --json </dev/null 2>/dev/null)
KEY=$(echo "$EXISTING" | jq -r '.key // empty' 2>/dev/null)
if [[ -n "$KEY" ]]; then
PREVIEW="${KEY:0:8}...${KEY: -4}"
# Always sync registry key → conf, even if the key was already there.
# Conf gets wiped on git pull / conf regeneration without touching the registry.
CONF_HAS_KEY=$(grep -oP "(?<=^\s*${VAR_NAME}=\")[^\"]*" "$CONF_FILE" 2>/dev/null || true)
if [[ "$CONF_HAS_KEY" == "$KEY" ]]; then
echo "API key valid ✅ — $VAR_NAME = $PREVIEW"
log "Key in registry and conf — no action needed"
exit 0
fi
log "Key in registry but conf is stale — syncing..."
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
fi
echo "API key synced to conf ✅ — $VAR_NAME = $PREVIEW"
exit 0
fi
log "Key not found in registry — creating new key..."
RAW=$(timeout 10 /usr/local/sbin/unraid-api apikey \
--name "$KEY_NAME" --create --overwrite \
--description "Varaverk plugin" --roles ADMIN --json </dev/null 2>&1)
if [[ -z "$RAW" ]]; then
error "unraid-api returned no output"
exit 1
fi
KEY=$(echo "$RAW" | jq -r '.key // empty' 2>/dev/null)
if [[ -z "$KEY" ]]; then
error "No key in unraid-api response: ${RAW:0:200}"
exit 1
fi
# ──────────────────────────────────────────────────────────────────────────────
if grep -q "^\s*${VAR_NAME}\s*=" "$CONF_FILE"; then
sed -i "s|^\(\s*${VAR_NAME}\s*=\s*\)\"[^\"]*\"|\1\"${KEY}\"|" "$CONF_FILE"
else
echo " ${VAR_NAME}=\"${KEY}\"" >> "$CONF_FILE"
fi
PREVIEW="${KEY:0:8}...${KEY: -4}"
log "Writing new key to: $CONF_FILE"
warn "API key renewed ✅ — $VAR_NAME = $PREVIEW (registry had lost it)"
# ── Push renewed key into each partner's OWN conf ─────────────────────────────
# Each host's conf is its complete keychest — no cross-host conf files needed.
# SSH_KEY is set by detect_hosts() — this server's outbound private key.
if [[ -z "$SSH_KEY" ]]; then
log "No SSH key configured — skipping partner push"
exit 0
fi
for host_var in $(compgen -v | grep -E '^HOST[0-9]+$'); do
partner_host="${!host_var}"
[[ -z "$partner_host" ]] && continue
[[ "${host_var,,}" == "${MY_ID,,}" ]] && continue
partner_slot="${host_var,,}" # e.g. host2
partner_ip=$(resolve_tailscale_ip "$partner_host" 2>/dev/null || true)
[[ -z "$partner_ip" ]] && { log "Cannot resolve IP for $partner_host — skipping"; continue; }
# Target is the partner's OWN conf on their machine
partner_conf="/boot/config/plugins/varaverk/Configurations/${partner_slot}.conf"
tmp=$(mktemp /tmp/vv_kp_XXXXXX.sh)
remote="/tmp/vv_kp_${RANDOM}.sh"
chmod 700 "$tmp"
# Key stays in the temp file — never appears in SSH command args
cat > "$tmp" <<PUSHSCRIPT
#!/bin/sh
target='${partner_conf}'
if grep -q "\b${VAR_NAME}\b" "\$target" 2>/dev/null; then
sed -i 's|^\(\\s*${VAR_NAME}\\s*=\\s*\)"[^"]*"|\1"${KEY}"|' "\$target"
else
printf ' ${VAR_NAME}="%s"\n' '${KEY}' >> "\$target"
fi
echo ok
PUSHSCRIPT
if timeout 10 scp -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "$tmp" "root@${partner_ip}:${remote}" 2>/dev/null; then
if timeout 10 ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \
-o StrictHostKeyChecking=no "root@${partner_ip}" \
"bash '${remote}'; rc=\$?; rm -f '${remote}'; exit \$rc" 2>/dev/null | grep -q ok; then
log "Key pushed to $partner_host ✅"
else
warn "Key push to $partner_host failed — they can create their own copy"
fi
else
warn "SCP to $partner_host failed — skipping"
fi
rm -f "$tmp"
done
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
#!/bin/bash
# ==============================================================================================
# ================================= Network Watchdog ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Services-layer connectivity — checks that the outside world can actually reach
# what it needs to reach. Silent when everything is reachable. Only fires when
# something in the connectivity chain has broken.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Check 1 — Internet Connectivity
# curl external endpoint → fail = alert + skip all remaining checks.
# Internet down means DDNS and NPM checks would false-positive — gating prevents noise.
#
# Check 2 — DDNS (Cloudflare)
# Public IP via ifconfig.me vs DNS record via dig @1.1.1.1.
# Match → pass (silent). Mismatch → restart DDNS container + notify.
# Container restart triggers an immediate Cloudflare record update.
#
# Check 3 — Tailscale
# tailscale status → Running → pass (silent). Not running → notify.
# Notify only — no restart attempt. Tailscale state issues warrant human review.
#
# Check 4 — NPM Proxy (external check)
# curl external URL → 2-strike system before restarting NginxProxyManager.
# Strike 1: warn + notify. Strike 2: restart NPM + notify + clear strikes.
# Strikes auto-clear when the external URL becomes reachable again.
# External check — verifies the full stack (DNS → NPM → backend), not just NPM running.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# docker restart requires root.
#
# Single Instance Lock
# acquire_lock prevents concurrent runs.
#
# Internet Gates All Checks
# If internet is down, DDNS and NPM checks are skipped — no cascade of false positives.
#
# Strike Before Acting on NPM
# Single curl failure could be transient DNS hiccup or CDN blip.
# Two consecutive failures confirms NPM is the problem.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# NETWORK_WATCHDOG_ENABLED toggle entire watchdog (default: true)
# NETWORK_WATCHDOG_INTERNET_URL endpoint for internet reachability check
# NETWORK_WATCHDOG_INTERNET_TIMEOUT curl timeout in seconds for internet check
# NETWORK_WATCHDOG_CHECK_TAILSCALE toggle tailscale check (default: true)
# NETWORK_WATCHDOG_NPM_TIMEOUT curl timeout for NPM external check
# NETWORK_WATCHDOG_NPM_STRIKE_LIMIT consecutive failures before NPM restart
# NETWORK_WATCHDOG_NPM_STATE_FILE strike count persistence (/tmp — resets on reboot)
#
# host*.conf (host-specific)
#
# HOST*_NETWORK_WATCHDOG_DDNS_DOMAIN domain to resolve and compare to public IP
# HOST*_NETWORK_WATCHDOG_DDNS_CONTAINER container to restart on DDNS mismatch
# HOST*_NETWORK_WATCHDOG_NPM_URL external URL to test full proxy stack
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# network_watchdog.sh
# Run all connectivity checks. Silent when healthy.
#
# network_watchdog.sh --dry-run
# Run all checks without restarting any containers.
#
# network_watchdog.sh --status
# Show configuration, current public IP, DNS record, NPM strike state.
#
# network_watchdog.sh --log
# Verbose output — show each check result even when passing.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Resolve host-specific config ━━━
# ==============================================================================================
_ddns_domain_var="${MY_ID}_NETWORK_WATCHDOG_DDNS_DOMAIN"
_ddns_container_var="${MY_ID}_NETWORK_WATCHDOG_DDNS_CONTAINER"
_npm_url_var="${MY_ID}_NETWORK_WATCHDOG_NPM_URL"
DDNS_DOMAIN="${!_ddns_domain_var:-}"
DDNS_CONTAINER="${!_ddns_container_var:-}"
NPM_URL="${!_npm_url_var:-}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
[[ "${NETWORK_WATCHDOG_ENABLED:-true}" != "true" ]] && echo "Network watchdog disabled" && exit 0
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be restarted"
log "$ICON_GEAR Config: internet=${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1} timeout=${NETWORK_WATCHDOG_INTERNET_TIMEOUT:-5}s tailscale=${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true} npm-strikes=${NETWORK_WATCHDOG_NPM_STRIKE_LIMIT:-2}"
log "$ICON_NET DDNS: ${DDNS_DOMAIN:-not configured} → ${DDNS_CONTAINER:-no container} NPM: ${NPM_URL:-not configured}"
touch "${NETWORK_WATCHDOG_NPM_STATE_FILE}" 2>/dev/null
# ━━━ Strike helpers ━━━
get_strikes() { grep "^${1}:" "${2}" 2>/dev/null | cut -d: -f2 || echo "0"; }
set_strikes() {
local key="$1" count="$2" file="$3"
if grep -q "^${key}:" "$file" 2>/dev/null; then
sed -i "s|^${key}:.*|${key}:${count}|" "$file"
else
echo "${key}:${count}" >> "$file"
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY NETWORK WATCHDOG STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Internet URL: ${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}"
echo "$ICON_GEAR DDNS domain: ${DDNS_DOMAIN:-not configured}"
echo "$ICON_GEAR DDNS container: ${DDNS_CONTAINER:-not configured}"
echo "$ICON_GEAR Tailscale: ${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}"
echo "$ICON_GEAR NPM URL: ${NPM_URL:-not configured}"
echo "$ICON_GEAR NPM strikes: $(get_strikes "npm" "${NETWORK_WATCHDOG_NPM_STATE_FILE}") / ${NETWORK_WATCHDOG_NPM_STRIKE_LIMIT:-2}"
echo ""
echo "── Current State ──"
if curl -sf --max-time "${NETWORK_WATCHDOG_INTERNET_TIMEOUT:-5}" \
"${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}" >/dev/null 2>&1; then
echo " $ICON_SUCCESS Internet: reachable"
else
echo " $ICON_ERROR Internet: NOT reachable"
fi
if [[ -n "$DDNS_DOMAIN" ]]; then
_pub=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null | tr -d '[:space:]')
_dns=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo " $ICON_GEAR Public IP: ${_pub:-unknown}"
echo " $ICON_GEAR DNS record: ${_dns:-unknown}"
[[ "$_pub" == "$_dns" ]] && \
echo " $ICON_SUCCESS DDNS: in sync" || \
echo " $ICON_ERROR DDNS: MISMATCH — public=$_pub dns=$_dns"
else
echo " $ICON_GEAR DDNS: not configured for $MY_ID"
fi
if [[ "${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}" == "true" ]] && command -v tailscale >/dev/null 2>&1; then
if tailscale status --json 2>/dev/null | grep -qE '"BackendState":\s*"Running"'; then
echo " $ICON_SUCCESS Tailscale: running"
else
echo " $ICON_ERROR Tailscale: NOT running"
fi
else
echo " $ICON_GEAR Tailscale: check disabled or not installed"
fi
if [[ -n "$NPM_URL" ]]; then
if curl -sf --max-time "${NETWORK_WATCHDOG_NPM_TIMEOUT:-10}" "$NPM_URL" >/dev/null 2>&1; then
echo " $ICON_SUCCESS NPM proxy: reachable ($NPM_URL)"
else
echo " $ICON_ERROR NPM proxy: NOT reachable ($NPM_URL)"
fi
else
echo " $ICON_GEAR NPM proxy: not configured for $MY_ID"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Check 1 — Internet Connectivity ━━━
# ==============================================================================================
echo "━━━ $ICON_NET Network Watchdog — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
ISSUES=0
if ! curl -sf --max-time "${NETWORK_WATCHDOG_INTERNET_TIMEOUT:-5}" \
"${NETWORK_WATCHDOG_INTERNET_URL:-https://1.1.1.1}" >/dev/null 2>&1; then
warn "$ICON_ERROR Internet not reachable — skipping DDNS, Tailscale, and NPM checks"
notify "Network watchdog: internet not reachable on $(hostname) ($MY_ID)" \
"Network Watchdog" "warning"
exit 1
fi
log "$ICON_SUCCESS Internet reachable"
# ==============================================================================================
# ━━━ Check 2 — DDNS ━━━
# ==============================================================================================
if [[ -n "$DDNS_DOMAIN" ]] && [[ -n "$DDNS_CONTAINER" ]]; then
PUBLIC_IP=$(curl -sf --max-time 5 https://ifconfig.me 2>/dev/null | tr -d '[:space:]')
DNS_IP=$(dig +short "$DDNS_DOMAIN" @1.1.1.1 2>/dev/null | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [[ -z "$PUBLIC_IP" ]]; then
warn "Could not determine public IP — skipping DDNS check"
elif [[ -z "$DNS_IP" ]]; then
warn "Could not resolve $DDNS_DOMAIN — skipping DDNS check"
elif [[ "$PUBLIC_IP" == "$DNS_IP" ]]; then
log "$ICON_SUCCESS DDNS in sync — $DDNS_DOMAIN → $DNS_IP"
else
warn "$ICON_ERROR DDNS mismatch — public=$PUBLIC_IP dns=$DNS_IP"
(( ISSUES++ ))
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $DDNS_CONTAINER"
else
warn "Restarting $DDNS_CONTAINER to trigger Cloudflare update..."
if docker restart "$DDNS_CONTAINER" >/dev/null 2>&1; then
warn "$DDNS_CONTAINER restarted ✅"
notify "DDNS mismatch on $(hostname) ($MY_ID) — $DDNS_DOMAIN was $DNS_IP, public is $PUBLIC_IP — $DDNS_CONTAINER restarted" \
"Network Watchdog" "warning"
else
warn "$DDNS_CONTAINER restart failed"
notify "DDNS mismatch on $(hostname) ($MY_ID) — $DDNS_CONTAINER restart failed — manual intervention needed" \
"Network Watchdog" "warning"
fi
fi
fi
else
log "DDNS check not configured for $MY_ID — skipping"
fi
# ==============================================================================================
# ━━━ Check 3 — Tailscale ━━━
# ==============================================================================================
if [[ "${NETWORK_WATCHDOG_CHECK_TAILSCALE:-true}" == "true" ]]; then
if ! command -v tailscale >/dev/null 2>&1; then
log "Tailscale not installed — skipping"
elif tailscale status --json 2>/dev/null | grep -qE '"BackendState":\s*"Running"'; then
log "$ICON_SUCCESS Tailscale running"
else
warn "$ICON_ERROR Tailscale not in Running state"
notify "Tailscale not running on $(hostname) ($MY_ID) — manual check needed" \
"Network Watchdog" "warning"
fi
fi
# ==============================================================================================
# ━━━ Check 4 — NPM Proxy (external) ━━━
# ==============================================================================================
if [[ -n "$NPM_URL" ]]; then
NPM_STRIKES=$(get_strikes "npm" "${NETWORK_WATCHDOG_NPM_STATE_FILE}")
NPM_STRIKE_LIMIT="${NETWORK_WATCHDOG_NPM_STRIKE_LIMIT:-2}"
if curl -sf --max-time "${NETWORK_WATCHDOG_NPM_TIMEOUT:-10}" "$NPM_URL" >/dev/null 2>&1; then
log "$ICON_SUCCESS NPM proxy reachable — $NPM_URL"
if [[ "$NPM_STRIKES" -gt 0 ]]; then
log "NPM strikes cleared (was $NPM_STRIKES)"
set_strikes "npm" 0 "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
fi
else
NPM_STRIKES=$(( NPM_STRIKES + 1 ))
set_strikes "npm" "$NPM_STRIKES" "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
(( ISSUES++ ))
if [[ "$NPM_STRIKES" -lt "$NPM_STRIKE_LIMIT" ]]; then
warn "$ICON_ERROR NPM proxy not reachable — $NPM_URL (strike $NPM_STRIKES/$NPM_STRIKE_LIMIT)"
notify "NPM proxy not reachable on $(hostname) ($MY_ID) — $NPM_URL (strike $NPM_STRIKES/$NPM_STRIKE_LIMIT)" \
"Network Watchdog" "warning"
else
warn "$ICON_ERROR NPM proxy strike limit reached ($NPM_STRIKES) — restarting NginxProxyManager"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart NginxProxyManager"
else
if docker restart NginxProxyManager >/dev/null 2>&1; then
warn "NginxProxyManager restarted ✅"
set_strikes "npm" 0 "${NETWORK_WATCHDOG_NPM_STATE_FILE}"
notify "NPM proxy restarted on $(hostname) ($MY_ID) — $NPM_URL was unreachable for $NPM_STRIKES cycles" \
"Network Watchdog" "warning"
else
warn "NginxProxyManager restart failed — manual intervention needed"
notify "NPM proxy restart FAILED on $(hostname) ($MY_ID) — manual intervention needed" \
"Network Watchdog" "warning"
fi
fi
fi
fi
else
log "NPM check not configured for $MY_ID — skipping"
fi
# ==============================================================================================
# ━━━ Exit ━━━
# ==============================================================================================
if [[ "$ISSUES" -gt 0 ]]; then
exit 1
else
echo "Network healthy ✅ ($(date '+%H:%M:%S'))"
exit 0
fi
@@ -0,0 +1,356 @@
#!/bin/bash
# ==============================================================================================
# ============================= Daily Sync Maintenance =========================================
# ==============================================================================================
# Daily orchestrator — runs the full daily maintenance window in the correct order.
# Schedule: 0 1 * * * (1am daily via User Scripts plugin)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# Pre-sync:
# git_pull_execute.sh — pull latest scripts first, always
#
# Arr Sync (arr_sync.sh):
# Syncs Lidarr/Sonarr/Radarr libraries across all nodes bidirectionally.
# All nodes agree on tracked library before any files are transferred.
# Remote nodes that don't have an arr running are skipped gracefully.
#
# Rsync window (DAILY_SYNC_SHARES per host):
# HOST*_DAILY_SYNC_SHARES — media shares spread to all nodes (no --delete)
# HOST*_PERSONAL_SHARES — encrypted personal shares
#
# Post-sync maintenance (DAILY_MAINTENANCE_SCRIPTS):
# media_shares_permissions.sh — fix ownership before arr cleanup
# media_cleaner.sh anime — remove junk from anime shares
# media_cleaner.sh media — remove junk from media shares
# lidarr_cleanup.sh — remove orphaned music files (local arr = truth)
# sonarr_cleanup.sh — remove orphaned TV files (local arr = truth)
# radarr_cleanup.sh — remove orphaned movie files (local arr = truth)
# docker_daily_restart.sh — restart containers needing daily restart
#
# ── WHY ORDER MATTERS ─────────────────────────────────────────────────────────────────────────
# git pull first — maintenance runs on latest code, not yesterday's
# arr sync before rsync — all nodes track the same library before files are spread;
# prevents remote arrs from searching for content already owned
# rsync before cleanup — cleanup sees fully spread state, rsync has no --delete
# permissions before arr cleanup — arrs need correct ownership to delete/rename
# arr cleanup after permissions — clean ownership = successful orphan deletion
# docker restart last — containers already processed by cleanup
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# Bidirectional — same script runs on both servers, correct direction automatic.
# detect_hosts() aliases DAILY_SYNC_SHARES and PERSONAL_SHARES from HOST*_ vars.
# No manual HOST1/HOST2 comparisons — MY_ID routes correctly on any server.
#
# ── DRIVE TEMP HANDLING ───────────────────────────────────────────────────────────────────────
# rsync.sh returns exit codes for temperature issues:
# exit 1 = temp WARN — skip this share, continue to next
# exit 2 = temp CRITICAL — abort ALL remaining syncs in this window
# All other failures — skip share, continue to next
#
# ── SILENT WHEN HEALTHY ───────────────────────────────────────────────────────────────────────
# Runs daily at 1am — clean run should produce minimal output.
# Each job reports log() on success (silent), warn()/error() on failure (visible).
# Summary always shown — gives window timing and share/job counts.
# Notify only on failure — successful daily maintenance doesn't need notification.
#
# ── CONFIGURATION (master.conf + host*.conf) ───────────────────────────────────────────
# HOST*_DAILY_SYNC_SHARES — shares pushed to mirror each day
# HOST*_PERSONAL_SHARES — encrypted personal shares
# DAILY_MAINTENANCE_SCRIPTS — maintenance jobs (permissions, cleanup, restart)
# DAILY_RSYNC_ENABLED — enable/disable rsync section
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# daily_sync_maintenance.sh — normal run
# daily_sync_maintenance.sh --dry-run — preview without syncing or changing
# daily_sync_maintenance.sh --log — verbose per-share/per-job output
# daily_sync_maintenance.sh --status — show configured shares and jobs
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
resolve_remote_ip
acquire_lock
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY DAILY SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Daily enabled: ${DAILY_RSYNC_ENABLED:-false}"
echo ""
echo "━━━ Daily Sync Shares ━━━"
for share in "${DAILY_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $share"
done
for share in "${PERSONAL_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $share (personal)"
done
echo ""
echo "━━━ Daily Maintenance Scripts ━━━"
for entry in "${DAILY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$entry" ]] && continue
echo " $ICON_GEAR ${entry##*/}"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── BUILD SHARE LIST via detect_hosts aliases ─────────────────────────────────────────────────
# detect_hosts() sets DAILY_SYNC_SHARES and PERSONAL_SHARES from HOST*_ vars
# No manual HOST1/HOST2 comparison needed — aliased automatically per server
# ==============================================================================================
ALL_SHARES=()
for share in "${DAILY_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && ALL_SHARES+=("$share")
done
for share in "${PERSONAL_SHARES[@]}"; do
[[ -n "$share" ]] && ALL_SHARES+=("$share")
done
SHARE_COUNT=${#ALL_SHARES[@]}
# ── Split maintenance scripts: git pull runs pre-sync, rest run post-sync ─────────────────────
PRE_SYNC_SCRIPTS=()
POST_SYNC_SCRIPTS=()
for script_entry in "${DAILY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
script_name=$(basename "${script_entry%% *}")
if [[ "$script_name" == "git_pull_execute.sh" ]]; then
PRE_SYNC_SCRIPTS+=("$script_entry")
else
POST_SYNC_SCRIPTS+=("$script_entry")
fi
done
# Helper — run a maintenance script, track pass/fail
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
WINDOW_START=$(date +%s)
JOB_PASS=()
JOB_FAIL=()
PASS=()
FAIL=()
SHARE_TIMES=()
echo ""
echo "━━━ $ICON_GEAR Daily Maintenance — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
# ==============================================================================================
# ━━━ Pre-sync — git pull ━━━
# ==============================================================================================
if [[ ${#PRE_SYNC_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GIT Pre-sync ━━━"
for script_entry in "${PRE_SYNC_SCRIPTS[@]}"; do
run_job "$script_entry"
done
fi
# ==============================================================================================
# ━━━ Arr Sync — library reconciliation before file spreading ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Arr Sync ━━━"
ARR_SYNC_SCRIPT="$SCRIPTS_ROOT/Media/arr_sync.sh"
if [[ "${ARR_SYNC_ENABLED:-true}" != "true" ]]; then
echo "ARR_SYNC_ENABLED=false — skipping"
elif [[ ! -f "$ARR_SYNC_SCRIPT" ]]; then
warn "arr_sync.sh not found at $ARR_SYNC_SCRIPT — skipping"
JOB_FAIL+=("arr_sync.sh")
else
_arr_sync_args=()
[[ "$DRY_RUN" == true ]] && _arr_sync_args+=("--dry-run")
if bash "$ARR_SYNC_SCRIPT" "${_arr_sync_args[@]}"; then
echo "Arr sync complete ✅"
JOB_PASS+=("arr_sync.sh")
else
warn "Arr sync completed with errors — continuing to rsync"
JOB_FAIL+=("arr_sync.sh")
fi
unset _arr_sync_args
fi
# ==============================================================================================
# ━━━ Media Share Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Media Share Sync — $SHARE_COUNT share(s) ━━━"
TOTAL_START=$(date +%s)
SHARE_INDEX=0
ABORT_ALL_SYNCS=false
if ! check_rsync_enabled "DAILY"; then
warn "Daily rsync disabled — skipping all $SHARE_COUNT share syncs"
warn "Proceeding to maintenance jobs..."
elif [[ "$SHARE_COUNT" -eq 0 ]]; then
warn "No shares configured for $MY_ID — check HOST*_DAILY_SYNC_SHARES in host*.conf"
else
# Pre-flight — connectivity then remote rootfs
check_connectivity
check_remote_rootfs
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for SHARE in "${ALL_SHARES[@]}"; do
(( SHARE_INDEX++ ))
SHARE_NAME=$(basename "$SHARE")
SHARE_START=$(date +%s)
echo ""
echo "━━━ $ICON_SYNC Share $SHARE_INDEX/$SHARE_COUNT: $SHARE_NAME ━━━"
if [[ "$ABORT_ALL_SYNCS" == true ]]; then
warn "$SHARE_NAME — skipped (drive temps CRITICAL earlier in window)"
FAIL+=("$SHARE_NAME:temp-critical")
continue
fi
bash "$RSYNC_SCRIPT" "$SHARE" $RSYNC_DRY
RSYNC_EXIT=$?
SHARE_TIMES+=("$SHARE_NAME:$(( $(date +%s) - SHARE_START ))")
case "$RSYNC_EXIT" in
0)
PASS+=("$SHARE_NAME")
log "$SHARE_NAME — done ✅"
;;
1)
FAIL+=("$SHARE_NAME:temp-warn")
warn "$SHARE_NAME skipped — drive temps too high"
;;
2)
FAIL+=("$SHARE_NAME:temp-critical")
ABORT_ALL_SYNCS=true
error "$SHARE_NAME aborted — drive temps CRITICAL, stopping all remaining syncs"
notify "Daily sync aborted on $(hostname) ($MY_ID) — drive temps CRITICAL during $SHARE_NAME" \
"Daily Sync" "warning"
;;
*)
FAIL+=("$SHARE_NAME")
error "$SHARE_NAME failed (exit $RSYNC_EXIT) — continuing to next share"
;;
esac
done
fi
TOTAL_END=$(date +%s)
# ==============================================================================================
# ━━━ Post-sync Maintenance Jobs ━━━
# ==============================================================================================
if [[ ${#POST_SYNC_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_CLEAN Post-sync Maintenance ━━━"
for script_entry in "${POST_SYNC_SCRIPTS[@]}"; do
echo ""
run_job "$script_entry"
done
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY DAILY MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo ""
echo "$ICON_SYNC Shares ($SHARE_COUNT):"
for entry in "${SHARE_TIMES[@]}"; do
sname="${entry%%:*}"
sdur="${entry##*:}"
if printf '%s\n' "${FAIL[@]}" | grep -q "^${sname}"; then
echo " $ICON_ERROR $sname — $(format_duration "$sdur")"
else
echo " $ICON_DONE $sname — $(format_duration "$sdur")"
fi
done
[[ "$SHARE_COUNT" -eq 0 || "${DAILY_RSYNC_ENABLED:-false}" == "false" ]] && \
echo " (rsync disabled)"
echo " Passed: ${#PASS[@]}/$SHARE_COUNT Failed: ${#FAIL[@]}/$SHARE_COUNT"
echo ""
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo "$ICON_GEAR Jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo ""
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
if [[ "$TOTAL_FAIL" -gt 0 ]]; then
warn "Status: $TOTAL_FAIL failure(s)"
notify "Daily maintenance completed with failures on $(hostname) ($MY_ID) — shares: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Daily Maintenance" "warning"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 1
else
echo "$ICON_DONE Status: all complete — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
@@ -0,0 +1,308 @@
#!/bin/bash
# ==============================================================================================
# ================================= ZFS Pool Scrub ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Triggers a ZFS scrub on all pools (or a specific pool), waits for completion,
# and sends a notification with any errors found. Reads every block on every pool
# and verifies checksums — catches silent corruption that would otherwise only
# surface when the corrupted data is read (possibly after redundancy can no
# longer help). Monthly recommended for all pools; quarterly minimum for large pools.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Starts a scrub on each pool, then polls every 60 seconds until all complete.
# Progress is shown every poll — safe to leave running or interrupt. ZFS scrub
# continues in the kernel even if the script is stopped — it does not depend on
# this script remaining alive.
#
# Scrub is safe to run while the pool is in use. It does consume I/O bandwidth —
# schedule during off-peak hours or maintenance windows.
#
# Pools in HOST*_ZFS_REPORT_IGNORE_POOLS are skipped automatically (single-disk
# VM pools, temp pools, etc.). Specifying a pool by name bypasses the ignore list.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent scrub starts on the same server.
#
# Scrub-in-Progress Check
# Detects pools already scrubbing and skips them rather than erroring — safe
# to run when a scrub may have been started by another path.
#
# SIGTERM Trap
# The poll loop exits cleanly on signal. The ZFS scrub continues regardless.
#
# Tool Validation
# platform_require_cmd confirms zpool and the notify script are present before use.
#
# Silent When Clean
# Only errors produce visible output and a notification.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_ZFS_REPORT_IGNORE_POOLS
# Pools to exclude from automatic scrub. Typically single-disk VM pools
# or temporary pools that do not need integrity checking.
# Aliased by detect_hosts() → ZFS_REPORT_IGNORE_POOLS.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# zfs_pool_scrub.sh
# Scrub all pools not in ZFS_REPORT_IGNORE_POOLS. Wait for completion.
#
# zfs_pool_scrub.sh poolname
# Scrub a specific pool by name. Bypasses the ignore list.
#
# zfs_pool_scrub.sh --status
# Show current scrub status for all pools and exit.
#
# zfs_pool_scrub.sh --dry-run
# Show which pools would be scrubbed. No scrub started.
#
# zfs_pool_scrub.sh --log
# Verbose progress output every poll cycle.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
TARGET_POOL="${PARSED_ARGS[0]:-}"
SCRUB_RUNNING=true
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
platform_require_cmd \
"$(command -v zpool 2>/dev/null || echo /sbin/zpool)" \
"--version" "" \
"zpool" || {
error "ZFS not available on this system — zpool not found"
exit 1
}
acquire_lock
# detect_hosts() sets MY_ID and aliases HOST*_ZFS_REPORT_IGNORE_POOLS
detect_hosts
# Build ignore pool map
declare -A IGNORE_MAP
for pool in "${ZFS_REPORT_IGNORE_POOLS[@]:-}"; do
[[ -n "$pool" ]] && IGNORE_MAP["$pool"]=1
done
log "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
log "Ignore pools: ${ZFS_REPORT_IGNORE_POOLS[*]:-none}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no scrubs will be started"
# SIGTERM trap — exit poll loop cleanly
trap 'warn "ZFS scrub script interrupted — scrub continues in background"; SCRUB_RUNNING=false; exit 0' \
SIGTERM SIGINT
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY ZFS SCRUB STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo ""
while IFS= read -r pool; do
[[ -z "$pool" ]] && continue
SCAN=$(zpool status "$pool" 2>/dev/null | grep "scan:")
IGNORED=""
[[ -n "${IGNORE_MAP[$pool]:-}" ]] && IGNORED=" (ignored)"
echo " $ICON_ZFS $pool${IGNORED} — ${SCAN:-no scan data}"
done < <(zpool list -H -o name 2>/dev/null)
echo ""
echo " Ignored pools: ${ZFS_REPORT_IGNORE_POOLS[*]:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── Build pool list ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
POOLS_TO_SCRUB=()
if [[ -n "$TARGET_POOL" ]]; then
# Specific pool — bypass ignore list, validate exists
if ! zpool list "$TARGET_POOL" >/dev/null 2>&1; then
error "Pool not found: $TARGET_POOL"
exit 1
fi
POOLS_TO_SCRUB=("$TARGET_POOL")
else
# All pools — skip ignored ones
while IFS= read -r pool; do
[[ -z "$pool" ]] && continue
if [[ -n "${IGNORE_MAP[$pool]:-}" ]]; then
log "Skipping $pool (in ZFS_REPORT_IGNORE_POOLS)"
continue
fi
POOLS_TO_SCRUB+=("$pool")
done < <(zpool list -H -o name 2>/dev/null)
fi
if [[ ${#POOLS_TO_SCRUB[@]} -eq 0 ]]; then
warn "No pools to scrub — all pools may be on the ignore list"
warn "Ignored: ${ZFS_REPORT_IGNORE_POOLS[*]:-none}"
exit 0
fi
log "Pools to scrub: ${POOLS_TO_SCRUB[*]}"
# ==============================================================================================
# ━━━ Start Scrubs ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_ZFS Starting ZFS Scrubs — $MY_ID ━━━"
START=$(date +%s)
STARTED=()
SKIPPED_POOLS=()
for pool in "${POOLS_TO_SCRUB[@]}"; do
# Check if scrub already in progress
ALREADY=$(zpool status "$pool" 2>/dev/null | grep "scan:" | grep -c "in progress" || true)
if [[ "$ALREADY" -gt 0 ]]; then
warn "$pool — scrub already in progress — joining existing scrub"
STARTED+=("$pool")
continue
fi
log "Starting scrub on $pool..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would scrub: $pool"
STARTED+=("$pool")
elif zpool scrub "$pool" 2>/dev/null; then
log "$pool scrub started ✅"
STARTED+=("$pool")
else
error "Failed to start scrub on $pool"
SKIPPED_POOLS+=("$pool")
fi
done
if [[ "$DRY_RUN" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY ZFS SCRUB SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
warn "DRY RUN — no scrubs started"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
if [[ ${#STARTED[@]} -eq 0 ]]; then
error "No scrubs were started — check pool status"
exit 1
fi
# ==============================================================================================
# ━━━ Poll Until Complete ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_TIME Waiting for Scrubs to Complete ━━━"
log "Polling every 60 seconds — scrubs may take hours on large pools"
log "Safe to interrupt — scrubs continue in background if script is stopped"
while [[ "$SCRUB_RUNNING" == true ]]; do
sleep 60
STILL_RUNNING=false
for pool in "${STARTED[@]}"; do
IN_PROGRESS=$(zpool status "$pool" 2>/dev/null | \
grep "scan:" | grep -c "in progress" || true)
if [[ "$IN_PROGRESS" -gt 0 ]]; then
STILL_RUNNING=true
# Show progress — always visible so user knows it's running
PROGRESS=$(zpool status "$pool" 2>/dev/null | \
grep "scan:" | grep -oE "[0-9]+\.[0-9]+% done")
REPAIRED=$(zpool status "$pool" 2>/dev/null | \
grep "scan:" | grep -oE "[0-9]+ repaired")
warn "$pool — scrub in progress ${PROGRESS:+$PROGRESS}${REPAIRED:+ ($REPAIRED)}"
fi
done
[[ "$STILL_RUNNING" == false ]] && SCRUB_RUNNING=false
done
END=$(date +%s)
warn "All scrubs complete — $(format_duration $(( END - START )))"
# ==============================================================================================
# ━━━ Results ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_ZFS Scrub Results ━━━"
POOLS_OK=()
POOLS_ERRORS=()
for pool in "${STARTED[@]}"; do
SCAN_LINE=$(zpool status "$pool" 2>/dev/null | grep "scan:")
ERRORS=$(zpool status "$pool" 2>/dev/null | \
grep "errors:" | grep -v "No known data errors")
if [[ -n "$ERRORS" ]]; then
error "$pool — ERRORS FOUND"
error " $SCAN_LINE"
error " $ERRORS"
POOLS_ERRORS+=("$pool")
else
echo "$pool — $SCAN_LINE"
POOLS_OK+=("$pool")
fi
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY ZFS SCRUB SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_ZFS Pools: ${#POOLS_TO_SCRUB[@]} to scrub"
echo "$ICON_SUCCESS Clean: ${#POOLS_OK[@]}"
[[ ${#POOLS_ERRORS[@]} -gt 0 ]] && echo "$ICON_ERROR Errors: ${#POOLS_ERRORS[@]}"
[[ ${#SKIPPED_POOLS[@]} -gt 0 ]] && warn "Failed start: ${SKIPPED_POOLS[*]}"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ ${#POOLS_ERRORS[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: ERRORS FOUND — ${POOLS_ERRORS[*]}"
notify "ZFS scrub errors on $(hostname) ($MY_ID) — pools with errors: ${POOLS_ERRORS[*]}" \
"ZFS Scrub" "warning"
elif [[ ${#POOLS_OK[@]} -gt 0 ]]; then
echo "$ICON_DONE Status: all ${#POOLS_OK[@]} pools clean ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#POOLS_ERRORS[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,247 @@
#!/bin/bash
# ==============================================================================================
# ============================= PHP-FPM Max Children ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Raises PHP-FPM pm.max_children to prevent WebGUI slowdowns under load. Run
# once at array start via ARRAY_START_SCRIPTS. Idempotent — silent when the
# value is already correct, no restart on clean boot.
#
# unRAID's WebGUI runs through PHP-FPM. The default pm.max_children is very
# low (48). Under load — multiple users, Docker operations, heavy dashboard
# usage — all PHP workers saturate and new requests queue. The WebGUI becomes
# slow or unresponsive.
#
# PHP_MAX_CHILDREN=250 is appropriate for 128GB RAM: ~2MB per worker = ~500MB
# total. Too high wastes RAM; too low causes slowdowns.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Idempotent Content Check
# Reads the current pm.max_children value before writing. If already at
# target → silent exit, no PHP-FPM restart. Restarting PHP-FPM unnecessarily
# disrupts active WebGUI sessions on every boot.
#
# Pattern Match Before Write
# Verifies the sed pattern finds pm.max_children in the config before
# applying any change. Prevents silent failures where sed succeeds but
# writes nothing because the key was missing or commented out.
#
# Apply Sequence
# 1. Read current pm.max_children from PHP_CONF
# 2. If already at target → exit silently
# 3. Verify sed pattern matches before writing
# 4. Apply sed replacement
# 5. Restart PHP-FPM via rc.php-fpm
# 6. Verify PHP-FPM process running after restart
# 7. Read back config to confirm value applied
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# Writing to /etc/php83/ requires root.
#
# Single Instance Lock
# acquire_lock prevents concurrent runs at array start.
#
# Process Verify
# Confirms PHP-FPM running after restart — errors if it failed to start.
#
# Config Verify
# Reads back config after restart to confirm the value was actually applied.
#
# Silent on Success
# Runs every boot — no noise when already correct.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# PHP_MAX_CHILDREN
# Target pm.max_children value. (default: 250)
#
# PHP_CONF
# Path to PHP-FPM www.conf. (default: /etc/php83/php-fpm.d/www.conf)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# php_fpm_max_children.sh
# Read current value. Update and restart PHP-FPM only if changed. Silent if correct.
#
# php_fpm_max_children.sh --dry-run
# Show current vs target value. No config write or restart.
#
# php_fpm_max_children.sh --status
# Show current pm.max_children, target, and PHP-FPM process state.
#
# php_fpm_max_children.sh --log
# Verbose output showing idempotent check, config write, and restart result.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — writing system config requires root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
validate_int PHP_MAX_CHILDREN "$PHP_MAX_CHILDREN"
require_var PHP_CONF
acquire_lock
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY PHP-FPM STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Config file: $PHP_CONF"
echo "$ICON_PHP Target: pm.max_children = $PHP_MAX_CHILDREN"
echo ""
if [[ -f "$PHP_CONF" ]]; then
CURRENT_VAL=$(grep -E "^pm\.max_children" "$PHP_CONF" 2>/dev/null | \
awk '{print $NF}')
if [[ "${CURRENT_VAL:-0}" -eq "$PHP_MAX_CHILDREN" ]]; then
echo " $ICON_SUCCESS Current: pm.max_children = $CURRENT_VAL (correct ✅)"
else
echo " $ICON_WARN Current: pm.max_children = ${CURRENT_VAL:-not set} (would update)"
fi
else
echo " $ICON_ERROR Config file not found: $PHP_CONF"
fi
echo ""
if pgrep -f "php-fpm" >/dev/null 2>&1; then
FPM_COUNT=$(pgrep -fc "php-fpm" 2>/dev/null || echo "?")
echo " $ICON_SUCCESS PHP-FPM: running ($FPM_COUNT worker(s))"
else
echo " $ICON_ERROR PHP-FPM: NOT running"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ PHP-FPM Config ━━━
# ==============================================================================================
START=$(date +%s)
if [[ ! -f "$PHP_CONF" ]]; then
error "PHP config file not found: $PHP_CONF"
notify "PHP-FPM config not found on $(hostname) ($MY_ID) — $PHP_CONF missing" \
"PHP-FPM" "warning"
exit 1
fi
# ── Idempotent check ─────────────────────────────────────────────────────────────────────────
CURRENT_VAL=$(grep -E "^pm\.max_children" "$PHP_CONF" 2>/dev/null | awk '{print $NF}')
if [[ "${CURRENT_VAL:-0}" -eq "$PHP_MAX_CHILDREN" ]]; then
echo "pm.max_children already $PHP_MAX_CHILDREN ✅"
exit 0
fi
warn "pm.max_children: ${CURRENT_VAL:-not set} → $PHP_MAX_CHILDREN"
# ── Verify pattern exists before writing ─────────────────────────────────────────────────────
if ! grep -qE "^pm\.max_children" "$PHP_CONF" 2>/dev/null; then
error "pm.max_children not found in $PHP_CONF — cannot apply"
error "Add 'pm.max_children = $PHP_MAX_CHILDREN' to $PHP_CONF manually"
notify "PHP-FPM pm.max_children not found in config on $(hostname) ($MY_ID)" \
"PHP-FPM" "warning"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set pm.max_children = $PHP_MAX_CHILDREN in $PHP_CONF"
warn "DRY RUN — would restart PHP-FPM"
exit 0
fi
# ── Apply setting ─────────────────────────────────────────────────────────────────────────────
log "Applying pm.max_children = $PHP_MAX_CHILDREN..."
if ! sed -i "s/^pm\.max_children.*/pm.max_children = $PHP_MAX_CHILDREN/" "$PHP_CONF"; then
error "Failed to update $PHP_CONF"
notify "PHP-FPM config update failed on $(hostname) ($MY_ID)" \
"PHP-FPM" "warning"
exit 1
fi
log "Config updated"
# ── Restart PHP-FPM ──────────────────────────────────────────────────────────────────────────
log "Restarting PHP-FPM..."
if ! platform_restart_service php-fpm; then
error "PHP-FPM restart command failed"
notify "PHP-FPM restart failed on $(hostname) ($MY_ID)" \
"PHP-FPM" "warning"
exit 1
fi
sleep 3 # Allow PHP-FPM workers to initialise
# ── Verify process running ────────────────────────────────────────────────────────────────────
if ! pgrep -f "php-fpm" >/dev/null 2>&1; then
error "PHP-FPM not running after restart — WebGUI may be broken"
notify "PHP-FPM failed to start after config update on $(hostname) ($MY_ID)" \
"PHP-FPM" "warning"
exit 1
fi
# ── Verify config reflects target ────────────────────────────────────────────────────────────
APPLIED_VAL=$(grep -E "^pm\.max_children" "$PHP_CONF" 2>/dev/null | awk '{print $NF}')
if [[ "${APPLIED_VAL:-0}" -ne "$PHP_MAX_CHILDREN" ]]; then
warn "Config reads pm.max_children = ${APPLIED_VAL:-unknown} — expected $PHP_MAX_CHILDREN"
warn "Check $PHP_CONF manually"
else
log "Verified: pm.max_children = $APPLIED_VAL ✅"
fi
FPM_WORKERS=$(pgrep -fc "php-fpm" 2>/dev/null || echo "?")
log "$ICON_PHP Workers running: $FPM_WORKERS"
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY PHP-FPM SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Config file: $PHP_CONF"
echo "$ICON_PHP Applied: pm.max_children = $PHP_MAX_CHILDREN"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
echo "$ICON_DONE Status: done ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
@@ -0,0 +1,334 @@
#!/bin/bash
# ==============================================================================================
# ============================= Emby Session Report ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Emby usage report via the Emby API. Scheduled weekly (Sunday 11am). Queries
# activity logs and session history to produce a summary of what was watched,
# by whom, and how often. No persistent state — queries fresh on every run.
#
# Reports: server info and uptime, active sessions and transcode ratio, library
# counts (movies/episodes/songs), activity history for the last EMBY_REPORT_DAYS
# days, top EMBY_REPORT_TOP_N content items, most active users, and ramdisk
# transcode status.
#
# Notifies only if transcoding exceeds 80% of streams — may indicate a client
# configuration issue. Silent on clean runs.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents duplicate reports running simultaneously.
#
# API Connectivity Check
# check_api() verifies Emby is reachable before any queries. API failure in
# one section does not abort the others — each section guards itself.
#
# Tool Validation
# Checks for curl and jq at startup — exits with a clear error if either is missing.
#
# Per-Host Credentials
# detect_hosts() aliases HOST*_EMBY_URL and HOST*_EMBY_API_KEY → EMBY_URL / EMBY_API_KEY.
# Each server reports on its own Emby instance automatically.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_EMBY_URL
# Emby server URL for this host. Aliased by detect_hosts() → EMBY_URL.
#
# HOST*_EMBY_API_KEY
# Emby API key for this host. Aliased by detect_hosts() → EMBY_API_KEY.
# Generate via Emby UI → Settings → API Keys → New API Key.
#
# master.conf
#
# EMBY_REPORT_DAYS
# Number of days to include in the activity history section. (default: 7)
#
# EMBY_REPORT_TOP_N
# Number of top content items to show in the report. (default: 10)
#
# RAMDISK_PATH
# Ramdisk mount path — used for transcode status reporting.
#
# TRANSCODE_LINK
# Symlink path — used to determine current transcode location.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# emby_session_report.sh
# Generate and send Emby usage report.
#
# emby_session_report.sh --dry-run
# Test API connectivity and generate report output. No notification sent.
#
# emby_session_report.sh --status
# Show Emby URL, API key (masked), and report configuration. Then exit.
#
# emby_session_report.sh --log
# Verbose per-section output during report generation.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for Emby API calls"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
notify "Emby report failed on $(hostname) — jq not installed" "Emby Report" "warning"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID and aliases EMBY_URL, EMBY_API_KEY
detect_hosts
require_var EMBY_URL
require_var EMBY_API_KEY
log "$ICON_GEAR Config: url=${EMBY_URL} period=${EMBY_REPORT_DAYS}d top=${EMBY_REPORT_TOP_N}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — API queried but no notification sent"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_EMBY Emby URL: $EMBY_URL"
echo "$ICON_TIME Period: Last ${EMBY_REPORT_DAYS} days"
echo "$ICON_EMBY Top N: ${EMBY_REPORT_TOP_N} items"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── API HELPER ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
emby_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf \
--max-time 15 \
-H "X-Emby-Token: $EMBY_API_KEY" \
-w "\n%{http_code}" \
"${EMBY_URL}/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Emby API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
# ==============================================================================================
# ━━━ Emby Session Report ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_EMBY Emby Session Report — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Period: Last ${EMBY_REPORT_DAYS} days"
echo ""
START=$(date +%s)
# ── Connectivity and server info ──────────────────────────────────────────────────────────────
if ! check_api "$EMBY_URL" "Emby" 10; then
notify "Emby report failed on $(hostname) — cannot connect to Emby at $EMBY_URL" \
"Emby Report" "warning"
exit 1
fi
SYSTEM_INFO=$(emby_api "System/Info" 2>/dev/null) || {
error "Cannot connect to Emby at $EMBY_URL"
exit 1
}
SERVER_NAME=$(echo "$SYSTEM_INFO" | jq -r '.ServerName // "Unknown"' 2>/dev/null)
SERVER_VERSION=$(echo "$SYSTEM_INFO" | jq -r '.Version // "Unknown"' 2>/dev/null)
log "$ICON_EMBY Connected to: $SERVER_NAME (v$SERVER_VERSION) ✅"
# ── Active Sessions ───────────────────────────────────────────────────────────────────────────
echo "━━━ $ICON_EMBY Active Sessions ━━━"
SESSIONS=$(emby_api "Sessions" 2>/dev/null) || { warn "Could not fetch sessions"; SESSIONS="[]"; }
ACTIVE_COUNT=$(echo "$SESSIONS" | \
jq '[.[] | select(.NowPlayingItem != null)] | length' 2>/dev/null || echo 0)
TRANSCODE_NOW=$(echo "$SESSIONS" | \
jq '[.[] | select(.NowPlayingItem != null) | select(.TranscodingInfo != null)] | length' \
2>/dev/null || echo 0)
DIRECT_NOW=$(( ACTIVE_COUNT - TRANSCODE_NOW ))
echo " $ICON_EMBY Active streams: $ACTIVE_COUNT"
echo " $ICON_EMBY Direct play: $DIRECT_NOW"
echo " $ICON_EMBY Transcoding: $TRANSCODE_NOW"
if [[ "$ACTIVE_COUNT" -gt 0 ]]; then
echo ""
echo " Now playing:"
echo "$SESSIONS" | jq -r '
.[] |
select(.NowPlayingItem != null) |
" \(.UserName // "Unknown") → \(.NowPlayingItem.Name // "Unknown") [\(if .TranscodingInfo != null then "transcode" else "direct" end)]"
' 2>/dev/null || true
fi
log "$ICON_EMBY Sessions: $ACTIVE_COUNT active ($DIRECT_NOW direct / $TRANSCODE_NOW transcode)"
echo ""
# ── Library Stats ─────────────────────────────────────────────────────────────────────────────
echo "━━━ $ICON_EMBY Library ━━━"
ITEMS=$(emby_api "Items/Counts" 2>/dev/null) || { warn "Could not fetch library counts"; ITEMS="{}"; }
MOVIE_COUNT=$(echo "$ITEMS" | jq '.MovieCount // 0' 2>/dev/null || echo 0)
EPISODE_COUNT=$(echo "$ITEMS" | jq '.EpisodeCount // 0' 2>/dev/null || echo 0)
SONG_COUNT=$(echo "$ITEMS" | jq '.SongCount // 0' 2>/dev/null || echo 0)
echo " $ICON_EMBY Movies: $MOVIE_COUNT"
echo " $ICON_EMBY Episodes: $EPISODE_COUNT"
echo " $ICON_EMBY Songs: $SONG_COUNT"
log "$ICON_EMBY Library: $MOVIE_COUNT movies $EPISODE_COUNT episodes $SONG_COUNT songs"
echo ""
# ── Activity History ──────────────────────────────────────────────────────────────────────────
# Query activity log for the configured period
echo "━━━ $ICON_EMBY Activity — Last ${EMBY_REPORT_DAYS} Days ━━━"
REPORT_START=$(date -d "${EMBY_REPORT_DAYS} days ago" '+%Y-%m-%dT00:00:00.000Z')
ACTIVITY=$(emby_api "System/ActivityLog/Entries?MinDate=${REPORT_START}&Limit=1000" \
2>/dev/null) || { warn "Could not fetch activity log"; ACTIVITY="{}"; }
TOTAL_PLAYS=$(echo "$ACTIVITY" | \
jq '[.Items // [] | .[] | select(.Type == "VideoPlayback" or .Type == "AudioPlayback")] | length' \
2>/dev/null || echo 0)
TRANSCODE_PLAYS=$(echo "$ACTIVITY" | \
jq '[.Items // [] | .[] | select(.Type == "VideoPlaybackUnplugged" or
(.Type == "VideoPlayback" and (.Overview // "" | contains("Transcode"))))] | length' \
2>/dev/null || echo 0)
echo " $ICON_EMBY Total play events: $TOTAL_PLAYS"
if [[ "$TOTAL_PLAYS" -gt 0 ]]; then
TRANSCODE_PCT=$(awk "BEGIN {printf \"%.0f\", ($TRANSCODE_PLAYS / $TOTAL_PLAYS) * 100}")
DIRECT_PCT=$(( 100 - TRANSCODE_PCT ))
echo " $ICON_EMBY Direct play: ~${DIRECT_PCT}%"
echo " $ICON_EMBY Transcoded: ~${TRANSCODE_PCT}%"
log "$ICON_EMBY Activity: $TOTAL_PLAYS plays — ~${DIRECT_PCT}% direct / ~${TRANSCODE_PCT}% transcode"
fi
echo ""
# ── Top Content ───────────────────────────────────────────────────────────────────────────────
echo "━━━ $ICON_EMBY Top ${EMBY_REPORT_TOP_N} Content ━━━"
TOP_ITEMS=$(emby_api "Items?SortBy=DatePlayed&SortOrder=Descending&Limit=${EMBY_REPORT_TOP_N}&Recursive=true&Fields=Overview&IncludeItemTypes=Movie,Episode" \
2>/dev/null) || { warn "Could not fetch top content"; TOP_ITEMS="{}"; }
TOP_COUNT=$(echo "$TOP_ITEMS" | jq '.Items // [] | length' 2>/dev/null || echo 0)
if [[ "$TOP_COUNT" -gt 0 ]]; then
echo "$TOP_ITEMS" | jq -r '
.Items // [] |
to_entries[] |
" \(.key + 1). \(.value.Name // "Unknown") [\(.value.Type // "")]"
' 2>/dev/null || warn "Could not parse top content"
else
echo " No recent play history found"
fi
echo ""
# ── Most Active Users ─────────────────────────────────────────────────────────────────────────
echo "━━━ $ICON_EMBY Most Active Users ━━━"
USERS=$(emby_api "Users" 2>/dev/null) || { warn "Could not fetch users"; USERS="[]"; }
USER_COUNT=$(echo "$USERS" | jq 'length' 2>/dev/null || echo 0)
echo " $ICON_EMBY Total users: $USER_COUNT"
if [[ "$USER_COUNT" -gt 0 ]]; then
echo "$USERS" | jq -r '
sort_by(.LastActivityDate // "0") |
reverse |
.[:5][] |
" \(.Name // "Unknown") — last active: \(.LastActivityDate // "never" | split("T")[0])"
' 2>/dev/null || true
fi
echo ""
# ── Ramdisk / Transcode Status ────────────────────────────────────────────────────────────────
echo "━━━ $ICON_RAM Transcode Status ━━━"
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}")
SYMLINK=$(readlink "$TRANSCODE_LINK" 2>/dev/null || echo "unknown")
echo " $ICON_RAM Ramdisk usage: ${RAMDISK_USED_GB}GB / ${RAMDISK_SIZE:-8G}"
echo " $ICON_LINK Symlink target: $SYMLINK"
if [[ "$SYMLINK" == *"ssd"* ]] || [[ "$SYMLINK" == *"cache"* ]]; then
warn "Transcode link pointing at SSD — ramdisk may be full"
fi
else
warn "Ramdisk not mounted at $RAMDISK_PATH"
fi
echo ""
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY EMBY REPORT SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_EMBY Server: $SERVER_NAME (v$SERVER_VERSION)"
echo "$ICON_EMBY Active: $ACTIVE_COUNT streams ($DIRECT_NOW direct / $TRANSCODE_NOW transcode)"
echo "$ICON_EMBY Library: $MOVIE_COUNT movies $EPISODE_COUNT episodes $SONG_COUNT songs"
echo "$ICON_EMBY Period: $TOTAL_PLAYS play events in last ${EMBY_REPORT_DAYS} days"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Only notify on issues — high transcode rate may indicate config problem
if [[ "$DRY_RUN" == false ]]; then
if [[ "$TOTAL_PLAYS" -gt 10 && "${TRANSCODE_PCT:-0}" -gt 80 ]]; then
notify "Emby report on $(hostname) — high transcode rate: ${TRANSCODE_PCT}% of $TOTAL_PLAYS plays — check direct play config" \
"Emby Report" "warning"
fi
fi
exit 0
@@ -0,0 +1,229 @@
#!/bin/bash
# ==============================================================================================
# ============================= Bulk Permissions Repair ========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Applies correct ownership and permissions to one or more specific paths.
# Targeted repair — faster than media_shares_permissions.sh, which processes
# every configured share. Use after failed transfers that left root:root ownership,
# containers writing as root before PUID/PGID was fixed, manual file copies, or
# new shares that need permissions applied before the next nightly run.
#
# Counts files with wrong ownership before fixing. A high count on a recently
# written share means a container has wrong PUID/PGID — add PUID=99 PGID=100
# to its Docker template. Common culprits: SABnzbd, qBittorrent, slskd.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Permissions Model
# Directories (PERMISSIONS_DIR_MODE, default 755):
# Owner (nobody) — rwx enter, list, create files
# Group (users) — r-x enter and list
# Others — r-x Samba guests can browse
# Files (PERMISSIONS_FILE_MODE, default 664):
# Owner (nobody) — rw read + write
# Group (users) — rw arrs can import and rename
# Others — r Samba guests can read
# No execute bit — media files are never executable
#
# Separate Passes
# Directories and files are chmod'd in separate find passes. A combined pass
# with mode 664 would wrongly strip the execute bit from directories, making
# them untraversable.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# chown requires root — exits immediately if not running as root.
#
# Path Existence Check
# Each path is verified before processing — missing paths log an error and
# are skipped rather than silently passing.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# Silent on Success
# Only failures and the wrong-owner diagnostic produce visible output.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# PERMISSIONS_OWNER
# Owner applied to all paths. (default: nobody:users)
#
# PERMISSIONS_DIR_MODE
# chmod mode for directories. (default: 755)
#
# PERMISSIONS_FILE_MODE
# chmod mode for files. (default: 664)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# bulk_permissions_repair.sh /path/to/share [/another/path ...]
# Apply ownership and permissions to each specified path.
#
# bulk_permissions_repair.sh /path/to/share --dry-run
# Show wrong-owner count per path. No chown or chmod applied.
#
# bulk_permissions_repair.sh /path/to/share --log
# Verbose output including per-path file counts and modes applied.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — chown requires root"
exit 1
fi
acquire_lock
# detect_hosts() sets MY_ID — used in summary
detect_hosts
if [[ ${#PARSED_ARGS[@]} -eq 0 ]]; then
error "No paths specified"
error "Usage: bulk_permissions_repair.sh /path/to/share [/another/path] [--dry-run]"
exit 1
fi
log "Dir mode: ${PERMISSIONS_DIR_MODE:-755}"
log "File mode: ${PERMISSIONS_FILE_MODE:-664}"
log "Owner: $PERMISSIONS_OWNER"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permissions will be changed"
# ==============================================================================================
# ━━━ Apply Permissions ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_PERMS Permissions Repair — $MY_ID ━━━"
START=$(date +%s)
PASS=()
FAIL=()
TOTAL_WRONG_OWNER=0
for share_path in "${PARSED_ARGS[@]}"; do
[[ -z "$share_path" ]] && continue
echo ""
echo "━━━ $ICON_PERMS $(basename "$share_path") ━━━"
if [[ ! -d "$share_path" ]]; then
error "$share_path — not found"
FAIL+=("$share_path")
continue
fi
# Count files for context — warn level so user knows what they're in for on large shares
FILE_COUNT=$(find "$share_path" -type f 2>/dev/null | wc -l)
DIR_COUNT=$(find "$share_path" -type d 2>/dev/null | wc -l)
SIZE=$(du -sh "$share_path" 2>/dev/null | cut -f1)
warn "$share_path — $FILE_COUNT files, $DIR_COUNT dirs ($SIZE)"
# Count files with wrong ownership before fixing — diagnostic
WRONG_OWNER=$(find "$share_path" \( ! -user nobody -o ! -group users \) \
2>/dev/null | wc -l)
if [[ "$WRONG_OWNER" -gt 0 ]]; then
warn "$WRONG_OWNER file(s) with wrong ownership — fixing..."
if [[ "$WRONG_OWNER" -gt 500 ]]; then
warn "High wrong-owner count — check container PUID/PGID settings (should be PUID=99 PGID=100)"
warn "Common culprits: SABnzbd, qBittorrent, slskd"
fi
TOTAL_WRONG_OWNER=$(( TOTAL_WRONG_OWNER + WRONG_OWNER ))
else
log "Ownership already correct — applying mode only"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would apply:"
warn " chown -R $PERMISSIONS_OWNER $share_path"
warn " find -type d → chmod ${PERMISSIONS_DIR_MODE:-755}"
warn " find -type f → chmod ${PERMISSIONS_FILE_MODE:-664}"
PASS+=("$(basename "$share_path")")
continue
fi
CHOWN_OK=true
CHMOD_DIR_OK=true
CHMOD_FILE_OK=true
# Apply ownership first
log "Applying ownership: $PERMISSIONS_OWNER..."
chown -R "$PERMISSIONS_OWNER" "$share_path" 2>/dev/null || CHOWN_OK=false
# Apply directory permissions — separate pass (dirs need execute bit)
log "Applying directory permissions: ${PERMISSIONS_DIR_MODE:-755}..."
find "$share_path" -type d \
-exec chmod "${PERMISSIONS_DIR_MODE:-755}" {} + 2>/dev/null || CHMOD_DIR_OK=false
# Apply file permissions — no execute bit on media files
log "Applying file permissions: ${PERMISSIONS_FILE_MODE:-664}..."
find "$share_path" -type f \
-exec chmod "${PERMISSIONS_FILE_MODE:-664}" {} + 2>/dev/null || CHMOD_FILE_OK=false
if [[ "$CHOWN_OK" == true && "$CHMOD_DIR_OK" == true && "$CHMOD_FILE_OK" == true ]]; then
log "$ICON_UNLOCKED $(basename "$share_path") — permissions applied ✅"
PASS+=("$(basename "$share_path")")
else
error "$(basename "$share_path") — repair failed"
error " chown: $CHOWN_OK chmod dirs: $CHMOD_DIR_OK chmod files: $CHMOD_FILE_OK"
FAIL+=("$(basename "$share_path")")
fi
done
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY PERMISSIONS REPAIR SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_PERMS Dir mode: ${PERMISSIONS_DIR_MODE:-755}"
echo "$ICON_PERMS File mode: ${PERMISSIONS_FILE_MODE:-664}"
echo "$ICON_PERMS Owner: $PERMISSIONS_OWNER"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
[[ ${#PASS[@]} -gt 0 ]] && log "Pass: ${PASS[*]}"
[[ ${#FAIL[@]} -gt 0 ]] && echo "$ICON_ERROR Fail: ${FAIL[*]}"
if [[ "$TOTAL_WRONG_OWNER" -gt 0 ]]; then
warn "Total wrong-owner files fixed: $TOTAL_WRONG_OWNER"
fi
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAIL[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: SOME REPAIRS FAILED"
notify "Permissions repair failed on $(hostname) — ${FAIL[*]}" \
"Permissions Repair" "warning"
else
echo "$ICON_DONE Status: done — ${#PASS[@]} path(s) repaired"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAIL[@]} -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,588 @@
#!/bin/bash
# ==============================================================================================
# ================================= Resource Manager ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pressure reduction layer — detects rising system load and reduces it before
# things break. Called by watchdog_orchestrator.sh every 15 minutes as a
# single-pass run. The middle layer between docker_watchdog.sh (fixes broken
# containers) and stability_watchdog.sh (reboots). Does neither of those things.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Three-Level Pressure Response
#
# Level 1 — SOFT (RAM < RW_RAM_SOFT_GB OR load > RW_LOAD_SOFT_MULTIPLIER × cores):
# Throttle SABnzbd download speed to RW_SABNZBD_SPEED_SOFT.
# Throttle qBittorrent download to RW_QBIT_DL_SOFT KB/s.
#
# Level 2 — MEDIUM (RAM < RW_RAM_MEDIUM_GB OR load > RW_LOAD_MEDIUM_MULTIPLIER × cores):
# Further throttle SABnzbd + qBittorrent to medium limits.
# docker pause RW_PAUSE_CONTAINERS — suspend without losing state, instantly reversible.
#
# Level 3 — HARD (RAM < RW_RAM_HARD_GB):
# docker stop RW_STOP_CONTAINERS — optional/heavy services (games, LocalAI, etc.).
# Write mem_shutdown_active=true → signals docker_watchdog to defer container restarts.
#
# Recovery
# Pressure must stay below current threshold for RW_RECOVER_CYCLES consecutive
# runs before restoring. De-escalates one level at a time — prevents re-triggering
# immediately after recovery. Level 3 additionally requires RAM >= RW_RAM_RECOVER_GB
# before containers are un-stopped.
#
# Coordination with docker_watchdog.sh
# At level 3, writes mem_shutdown_active=true to RW_STATE_FILE.
# docker_watchdog.sh reads this and defers all container restart logic.
# Without this, docker_watchdog would immediately restart containers that were
# just stopped to free RAM — defeating the purpose of level 3.
# Cleared when pressure resolves and containers are restarted.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# docker pause/stop require root.
#
# Single Instance Lock
# acquire_lock prevents concurrent runs from racing on state file writes.
#
# RW_CRITICAL_CONTAINERS
# Containers listed here are never paused or stopped regardless of pressure level.
#
# RW_ENABLED Flag
# Set RW_ENABLED=false to disable the entire script without removing it from
# the orchestrator schedule.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
# RW_ENABLED, RW_STATE_FILE
# RW_RAM_SOFT_GB, RW_RAM_MEDIUM_GB, RW_RAM_HARD_GB, RW_RAM_RECOVER_GB
# RW_LOAD_SOFT_MULTIPLIER, RW_LOAD_MEDIUM_MULTIPLIER
# RW_RECOVER_CYCLES
# RW_SABNZBD_ENABLED, RW_SABNZBD_SPEED_SOFT, RW_SABNZBD_SPEED_MEDIUM
# RW_QBIT_ENABLED, RW_QBIT_DL_SOFT, RW_QBIT_DL_MEDIUM
# RW_CRITICAL_CONTAINERS — never paused or stopped regardless of pressure
#
# host*.conf (aliased by detect_hosts())
# HOST*_RW_PAUSE_CONTAINERS — docker pause at medium pressure
# HOST*_RW_STOP_CONTAINERS — docker stop at hard pressure
# HOST*_SABNZBD_URL, HOST*_SABNZBD_API_KEY
# HOST*_QBIT_URL, HOST*_QBIT_USERNAME, HOST*_QBIT_PASSWORD
#
# ==============================================================================================
# STATE FILES
# ==============================================================================================
#
# RW_STATE_FILE
# Pressure level, recovery cycle count, stopped container list, and the
# mem_shutdown_active coordination flag read by docker_watchdog.sh.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# resource_watchdog.sh
# Single-pass pressure check. Apply actions if threshold crossed. Silent if below.
#
# resource_watchdog.sh --dry-run
# Show current pressure level and what would be throttled/paused/stopped. No changes.
#
# resource_watchdog.sh --status
# Show current pressure level, active actions, recovery cycle count, stopped containers.
#
# resource_watchdog.sh --log
# Verbose per-check output — show RAM, load, each threshold comparison, each action.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if [[ "${RW_ENABLED:-true}" != "true" ]]; then
echo "Resource Manager disabled (RW_ENABLED=false)"
exit 0
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
DOCKER_TIMEOUT=15
log "$ICON_GEAR Config: soft=RAM<${RW_RAM_SOFT_GB}GB/load≥${RW_LOAD_SOFT_THRESH} medium=RAM<${RW_RAM_MEDIUM_GB}GB/load≥${RW_LOAD_MEDIUM_THRESH} hard=RAM<${RW_RAM_HARD_GB}GB recover=RAM≥${RW_RAM_RECOVER_GB}GB cycles=${RW_RECOVER_CYCLES}"
log "$ICON_CONTAINERS Pause at medium: ${RW_PAUSE_CONTAINERS[*]:-none} Stop at hard: ${RW_STOP_CONTAINERS[*]:-none}"
touch "$RW_STATE_FILE" 2>/dev/null || {
error "Cannot create state file: $RW_STATE_FILE"
exit 1
}
# ── Exit Trap — restart containers stopped this run if script crashes ──────────────────────────
declare -a _RW_TRAP_STOPPED=()
_rw_trap_restart_stopped() {
[[ ${#_RW_TRAP_STOPPED[@]} -eq 0 ]] && return
for c in "${_RW_TRAP_STOPPED[@]}"; do
[[ -z "$c" ]] && continue
if docker inspect "$c" >/dev/null 2>&1; then
warn "Exit trap: restarting $c (stopped but state not persisted)"
docker start "$c" >/dev/null 2>&1 || warn " Failed to restart $c"
fi
done
}
trap "_release_all_locks; _rw_trap_restart_stopped" EXIT
# ==============================================================================================
# ━━━ State Helpers ━━━
# ==============================================================================================
# rm_state_get/set use : separator for RM-internal state
# rm_state_get_eq/set_eq use = separator for docker_watchdog coordination flags
rm_state_get() {
grep -E "^${1}:" "$RW_STATE_FILE" 2>/dev/null | cut -d: -f2-
}
rm_state_set() {
local key="$1" val="$2"
grep -vE "^${key}:" "$RW_STATE_FILE" 2>/dev/null > "${RW_STATE_FILE}.tmp"
echo "${key}:${val}" >> "${RW_STATE_FILE}.tmp"
mv "${RW_STATE_FILE}.tmp" "$RW_STATE_FILE"
}
rm_state_get_eq() {
grep -E "^${1}=" "$RW_STATE_FILE" 2>/dev/null | cut -d= -f2-
}
rm_state_set_eq() {
local key="$1" val="$2"
grep -vE "^${key}=" "$RW_STATE_FILE" 2>/dev/null > "${RW_STATE_FILE}.tmp"
echo "${key}=${val}" >> "${RW_STATE_FILE}.tmp"
mv "${RW_STATE_FILE}.tmp" "$RW_STATE_FILE"
}
# ==============================================================================================
# ━━━ Load State ━━━
# ==============================================================================================
CURRENT_LEVEL=$(rm_state_get "rm_action_level"); CURRENT_LEVEL=${CURRENT_LEVEL:-0}
RECOVER_CYCLES=$(rm_state_get "rm_recover_cycles"); RECOVER_CYCLES=${RECOVER_CYCLES:-0}
PAUSED_LIST=$(rm_state_get "rm_paused_containers"); PAUSED_LIST=${PAUSED_LIST:-""}
STOPPED_LIST=$(rm_state_get "rm_stopped_containers"); STOPPED_LIST=${STOPPED_LIST:-""}
# ==============================================================================================
# ━━━ Pressure Calculation ━━━
# ==============================================================================================
TOTAL_CORES=$(nproc)
MEM_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
MEM_GB=$(( MEM_KB / 1024 / 1024 ))
LOAD=$(awk '{print $1}' /proc/loadavg)
LOAD_INT=$(printf "%.0f" "$LOAD")
RW_LOAD_SOFT_THRESH=$(awk "BEGIN{printf \"%.0f\", $TOTAL_CORES * ${RW_LOAD_SOFT_MULTIPLIER:-2.0}}")
RW_LOAD_MEDIUM_THRESH=$(awk "BEGIN{printf \"%.0f\", $TOTAL_CORES * ${RW_LOAD_MEDIUM_MULTIPLIER:-3.0}}")
TARGET_LEVEL=0
TARGET_REASON=""
if [[ "$MEM_GB" -lt "${RW_RAM_HARD_GB:-6}" ]]; then
TARGET_LEVEL=3
TARGET_REASON="RAM ${MEM_GB}GB < hard threshold ${RW_RAM_HARD_GB}GB"
elif [[ "$MEM_GB" -lt "${RW_RAM_MEDIUM_GB:-8}" ]] || [[ "$LOAD_INT" -ge "$RW_LOAD_MEDIUM_THRESH" ]]; then
TARGET_LEVEL=2
[[ "$MEM_GB" -lt "${RW_RAM_MEDIUM_GB:-8}" ]] && TARGET_REASON="RAM ${MEM_GB}GB < medium threshold ${RW_RAM_MEDIUM_GB}GB"
[[ "$LOAD_INT" -ge "$RW_LOAD_MEDIUM_THRESH" ]] && TARGET_REASON="${TARGET_REASON:+$TARGET_REASON, }load ${LOAD} >= medium threshold ${RW_LOAD_MEDIUM_THRESH}"
elif [[ "$MEM_GB" -lt "${RW_RAM_SOFT_GB:-12}" ]] || [[ "$LOAD_INT" -ge "$RW_LOAD_SOFT_THRESH" ]]; then
TARGET_LEVEL=1
[[ "$MEM_GB" -lt "${RW_RAM_SOFT_GB:-12}" ]] && TARGET_REASON="RAM ${MEM_GB}GB < soft threshold ${RW_RAM_SOFT_GB}GB"
[[ "$LOAD_INT" -ge "$RW_LOAD_SOFT_THRESH" ]] && TARGET_REASON="${TARGET_REASON:+$TARGET_REASON, }load ${LOAD} >= soft threshold ${RW_LOAD_SOFT_THRESH}"
fi
LEVEL_NAMES=("normal" "soft" "medium" "hard")
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY RESOURCE MANAGER STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo ""
echo "── Current State ──"
echo " Action level: $CURRENT_LEVEL (${LEVEL_NAMES[$CURRENT_LEVEL]:-unknown})"
echo " Target level: $TARGET_LEVEL (${LEVEL_NAMES[$TARGET_LEVEL]:-unknown})"
echo " Recover cycles: $RECOVER_CYCLES / ${RW_RECOVER_CYCLES:-3}"
[[ -n "$PAUSED_LIST" ]] && echo " Paused: $PAUSED_LIST"
[[ -n "$STOPPED_LIST" ]] && echo " Stopped: $STOPPED_LIST"
MEM_SHUTDOWN_ACTIVE=$(rm_state_get_eq "mem_shutdown_active")
[[ "$MEM_SHUTDOWN_ACTIVE" == "true" ]] && warn " docker_watchdog DEFERRED (mem_shutdown_active=true)"
echo ""
echo "── System Pressure ──"
echo " RAM free: ${MEM_GB}GB (soft:<${RW_RAM_SOFT_GB} medium:<${RW_RAM_MEDIUM_GB} hard:<${RW_RAM_HARD_GB} recover:>=${RW_RAM_RECOVER_GB})"
echo " Load avg: ${LOAD} (soft:>=${RW_LOAD_SOFT_THRESH} medium:>=${RW_LOAD_MEDIUM_THRESH} cores:${TOTAL_CORES})"
echo ""
echo "── Configuration ──"
echo " SABnzbd throttle: ${RW_SABNZBD_ENABLED:-true} soft=${RW_SABNZBD_SPEED_SOFT} medium=${RW_SABNZBD_SPEED_MEDIUM}"
echo " qBit throttle: ${RW_QBIT_ENABLED:-true} soft=${RW_QBIT_DL_SOFT}KB/s medium=${RW_QBIT_DL_MEDIUM}KB/s"
echo ""
echo "── Container Lists (this host) ──"
echo " Pause at medium: ${RW_PAUSE_CONTAINERS[*]:-none configured}"
echo " Stop at hard: ${RW_STOP_CONTAINERS[*]:-none configured}"
echo " Critical (never touched): ${RW_CRITICAL_CONTAINERS[*]:-none}"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Critical Container Guard ━━━
# ==============================================================================================
# Returns 0 if container is safe to pause/stop, 1 if it is critical
is_critical() {
local container="$1"
for c in "${RW_CRITICAL_CONTAINERS[@]:-}"; do
[[ "$c" == "$container" ]] && return 1
done
return 0
}
# ==============================================================================================
# ━━━ SABnzbd API ━━━
# ==============================================================================================
sabnzbd_set_speed() {
local speed="$1"
[[ "${RW_SABNZBD_ENABLED:-true}" != "true" ]] && return 0
[[ -z "$SABNZBD_URL" || -z "$SABNZBD_API_KEY" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set SABnzbd speed to $speed"
return 0
fi
curl -sf --max-time 10 \
"${SABNZBD_URL}/api?mode=config&name=speedlimit&value=${speed}&apikey=${SABNZBD_API_KEY}" \
>/dev/null 2>&1 && log "SABnzbd speed → $speed" || warn "SABnzbd API call failed"
}
# ==============================================================================================
# ━━━ qBittorrent API ━━━
# ==============================================================================================
QBIT_COOKIE="/tmp/rm_qbit_cookie.txt"
qbit_login() {
[[ "${RW_QBIT_ENABLED:-true}" != "true" ]] && return 0
[[ -z "$QBIT_URL" || -z "$QBIT_USERNAME" || -z "$QBIT_PASSWORD" ]] && return 0
curl -sf --max-time 10 -c "$QBIT_COOKIE" \
-X POST "${QBIT_URL}/api/v2/auth/login" \
-d "username=${QBIT_USERNAME}&password=${QBIT_PASSWORD}" >/dev/null 2>&1
}
qbit_set_dl_limit() {
local kbps="$1" # KB/s — 0 = unlimited
[[ "${RW_QBIT_ENABLED:-true}" != "true" ]] && return 0
[[ -z "$QBIT_URL" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would set qBit download limit to ${kbps}KB/s"
return 0
fi
local bps=$(( kbps * 1024 ))
qbit_login
curl -sf --max-time 10 -b "$QBIT_COOKIE" \
-X POST "${QBIT_URL}/api/v2/transfer/setDownloadLimit" \
-d "limit=${bps}" >/dev/null 2>&1 && log "qBit download limit → ${kbps}KB/s" || warn "qBit API call failed"
}
# ==============================================================================================
# ━━━ Container Actions ━━━
# ==============================================================================================
# Pause a list of containers — returns newline-separated list of actually-paused containers
pause_containers() {
local actually_paused=()
for container in "$@"; do
[[ -z "$container" ]] && continue
is_critical "$container" || { log "$container — critical, skipping pause"; continue; }
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)
if [[ "$status" != "running" ]]; then
log "$container — not running (status: ${status:-unknown}), skipping pause"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would docker pause $container"
actually_paused+=("$container")
continue
fi
if timeout "$DOCKER_TIMEOUT" docker pause "$container" >/dev/null 2>&1; then
warn "Paused $container (medium pressure)"
actually_paused+=("$container")
else
error "Failed to pause $container"
fi
done
printf '%s,' "${actually_paused[@]}" | sed 's/,$//'
}
# Unpause a comma-separated list of containers
unpause_containers() {
local IFS=','
for container in $1; do
[[ -z "$container" ]] && continue
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)
if [[ "$status" != "paused" ]]; then
log "$container — not paused (status: ${status:-unknown}), skipping unpause"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would docker unpause $container"
continue
fi
if timeout "$DOCKER_TIMEOUT" docker unpause "$container" >/dev/null 2>&1; then
warn "Unpaused $container (pressure reduced)"
else
error "Failed to unpause $container"
fi
done
}
# Stop a list of containers — returns comma-separated list of actually-stopped containers
stop_containers() {
local actually_stopped=()
for container in "$@"; do
[[ -z "$container" ]] && continue
is_critical "$container" || { log "$container — critical, skipping stop"; continue; }
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)
if [[ "$status" != "running" && "$status" != "paused" ]]; then
log "$container — not running (status: ${status:-unknown}), skipping stop"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would docker stop $container"
actually_stopped+=("$container")
continue
fi
if timeout "$DOCKER_TIMEOUT" docker stop "$container" >/dev/null 2>&1; then
warn "Stopped $container (hard pressure)"
actually_stopped+=("$container")
_RW_TRAP_STOPPED+=("$container")
else
error "Failed to stop $container"
fi
done
printf '%s,' "${actually_stopped[@]}" | sed 's/,$//'
}
# Start a comma-separated list of containers (only those RM stopped)
start_containers() {
local IFS=','
for container in $1; do
[[ -z "$container" ]] && continue
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null)
if [[ "$status" == "running" ]]; then
log "$container — already running"
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would docker start $container"
continue
fi
if timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1; then
warn "Started $container (pressure cleared)"
else
error "Failed to start $container"
fi
done
}
# ==============================================================================================
# ━━━ Apply Level Actions ━━━
# ==============================================================================================
apply_level_1() {
log "Applying level 1 (soft) — throttling downloaders"
sabnzbd_set_speed "${RW_SABNZBD_SPEED_SOFT:-50M}"
qbit_set_dl_limit "${RW_QBIT_DL_SOFT:-51200}"
}
apply_level_2() {
log "Applying level 2 (medium) — throttling + pausing background containers"
sabnzbd_set_speed "${RW_SABNZBD_SPEED_MEDIUM:-10M}"
qbit_set_dl_limit "${RW_QBIT_DL_MEDIUM:-10240}"
if [[ ${#RW_PAUSE_CONTAINERS[@]} -gt 0 ]]; then
local newly_paused
newly_paused=$(pause_containers "${RW_PAUSE_CONTAINERS[@]}")
# Merge with existing paused list (avoid duplicates on re-escalation)
if [[ -n "$newly_paused" ]]; then
if [[ -n "$PAUSED_LIST" ]]; then
PAUSED_LIST="${PAUSED_LIST},${newly_paused}"
else
PAUSED_LIST="$newly_paused"
fi
fi
fi
}
apply_level_3() {
log "Applying level 3 (hard) — stopping optional containers"
sabnzbd_set_speed "${RW_SABNZBD_SPEED_MEDIUM:-10M}" # already at medium from level 2
qbit_set_dl_limit "${RW_QBIT_DL_MEDIUM:-10240}"
if [[ ${#RW_STOP_CONTAINERS[@]} -gt 0 ]]; then
local newly_stopped
newly_stopped=$(stop_containers "${RW_STOP_CONTAINERS[@]}")
if [[ -n "$newly_stopped" ]]; then
if [[ -n "$STOPPED_LIST" ]]; then
STOPPED_LIST="${STOPPED_LIST},${newly_stopped}"
else
STOPPED_LIST="$newly_stopped"
fi
fi
fi
# Signal docker_watchdog to defer container restarts
rm_state_set_eq "mem_shutdown_active" "true"
warn "mem_shutdown_active=true — docker_watchdog will defer restarts"
}
# ==============================================================================================
# ━━━ Restore Level Actions ━━━
# ==============================================================================================
restore_level_3() {
echo "Restoring from level 3 — starting stopped containers"
if [[ -n "$STOPPED_LIST" ]]; then
start_containers "$STOPPED_LIST"
STOPPED_LIST=""
fi
rm_state_set_eq "mem_shutdown_active" "false"
warn "mem_shutdown_active=false — docker_watchdog restoring normal operation"
}
restore_level_2() {
echo "Restoring from level 2 — unpausing containers"
if [[ -n "$PAUSED_LIST" ]]; then
unpause_containers "$PAUSED_LIST"
PAUSED_LIST=""
fi
}
restore_level_1() {
echo "Restoring from level 1 — removing downloader throttle"
sabnzbd_set_speed "0"
qbit_set_dl_limit 0
}
# ==============================================================================================
# ━━━ Pressure Decision ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Resource Manager — $MY_ID — $(date '+%H:%M:%S') ━━━"
echo " RAM: ${MEM_GB}GB free Load: ${LOAD} Action: ${CURRENT_LEVEL} → ${TARGET_LEVEL} (${LEVEL_NAMES[$TARGET_LEVEL]:-unknown})"
if [[ "$TARGET_LEVEL" -gt "$CURRENT_LEVEL" ]]; then
# ── Escalate ────────────────────────────────────────────────────────────────────────────
warn "Pressure escalating to level $TARGET_LEVEL — $TARGET_REASON"
notify "Resource Manager: pressure level $TARGET_LEVEL on $(hostname) ($MY_ID) — $TARGET_REASON" \
"Resource Manager" "warning"
for (( lvl = CURRENT_LEVEL + 1; lvl <= TARGET_LEVEL; lvl++ )); do
case "$lvl" in
1) apply_level_1 ;;
2) apply_level_2 ;;
3) apply_level_3 ;;
esac
done
rm_state_set "rm_action_level" "$TARGET_LEVEL"
rm_state_set "rm_recover_cycles" 0
elif [[ "$TARGET_LEVEL" -lt "$CURRENT_LEVEL" ]]; then
# ── Tracking recovery ───────────────────────────────────────────────────────────────────
RECOVER_CYCLES=$(( RECOVER_CYCLES + 1 ))
rm_state_set "rm_recover_cycles" "$RECOVER_CYCLES"
log "Pressure at level $TARGET_LEVEL — recovery cycle $RECOVER_CYCLES/${RW_RECOVER_CYCLES:-3} before restoring level $CURRENT_LEVEL actions"
if [[ "$RECOVER_CYCLES" -ge "${RW_RECOVER_CYCLES:-3}" ]]; then
# Level 3 de-escalation requires RAM above recover threshold
if [[ "$CURRENT_LEVEL" -ge 3 && "$MEM_GB" -lt "${RW_RAM_RECOVER_GB:-20}" ]]; then
warn "Level 3 restore blocked — RAM ${MEM_GB}GB still below recover threshold ${RW_RAM_RECOVER_GB}GB"
else
warn "Pressure sustained below level $CURRENT_LEVEL — restoring"
case "$CURRENT_LEVEL" in
3) restore_level_3 ;;
2) restore_level_2 ;;
1) restore_level_1 ;;
esac
NEW_LEVEL=$(( CURRENT_LEVEL - 1 ))
rm_state_set "rm_action_level" "$NEW_LEVEL"
rm_state_set "rm_recover_cycles" 0
rm_state_set "rm_paused_containers" "$PAUSED_LIST"
rm_state_set "rm_stopped_containers" "$STOPPED_LIST"
if [[ "$NEW_LEVEL" -gt 0 ]]; then
warn "De-escalated to level $NEW_LEVEL (${LEVEL_NAMES[$NEW_LEVEL]}) — ${RW_RECOVER_CYCLES:-3} more cycles to fully clear"
else
log "All pressure cleared — system at normal operation ✅"
notify "Resource Manager: pressure resolved on $(hostname) ($MY_ID) — system back to normal" \
"Resource Manager" "normal"
fi
fi
fi
else
# ── Steady state ────────────────────────────────────────────────────────────────────────
if [[ "$CURRENT_LEVEL" -gt 0 ]]; then
echo "Pressure holding at level $CURRENT_LEVEL — waiting for sustained recovery"
else
log "System normal — RAM ${MEM_GB}GB free | load ${LOAD} | ${TOTAL_CORES} cores | SABnzbd=${RW_SABNZBD_ENABLED:-true} qBit=${RW_QBIT_ENABLED:-true} ✅"
fi
rm_state_set "rm_recover_cycles" 0
fi
# ==============================================================================================
# ━━━ Persist State ━━━
# ==============================================================================================
rm_state_set "rm_paused_containers" "$PAUSED_LIST"
rm_state_set "rm_stopped_containers" "$STOPPED_LIST"
trap "_release_all_locks" EXIT # state persisted — disable restart trap, keep lock cleanup
# Touch state file each run so docker_watchdog stale guard sees fresh mtime
touch "$RW_STATE_FILE" 2>/dev/null
@@ -0,0 +1,457 @@
#!/bin/bash
# ==============================================================================================
# ============================= Rsync Core Script ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Core rsync engine for the two-server ecosystem. Called per share or per
# appdata profile by orchestrators (daily_sync_maintenance, weekly_sync_maintenance,
# critical_sync_maintenance) and directly for manual or scheduled dirty syncs.
#
# Profile is inferred from the directory basename (lowercased). Override with
# --profile=name for explicit selection. If no profile matches, global defaults
# from master.conf apply and no containers are stopped.
#
# After each sync, logs transfer data to bandwidth_monitor.sh for the weekly
# bandwidth report. Silent on success — only failures produce visible output.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Profiles define per-share behavior:
# PROFILE_CRITICAL_CONTAINER_NAMES — containers stopped on both servers before sync
# PROFILE_DELAYED_CONTAINERS — containers with a delay before restart after sync
# PROFILE_CONTAINER_DELAY — seconds before delayed containers start
# PROFILE_RSYNC_OPTS — rsync flags (does not inherit DEFAULT_RSYNC_OPTS)
# PROFILE_BW_LIMIT — bandwidth limit in KB/s
# PROFILE_RETRY_COUNT — retry attempts on failure
# PROFILE_SLEEP — seconds between retry attempts
# PROFILE_EXCLUDE_DIRS — paths excluded from transfer
# PROFILE_REMOTE_RESTART_CONTAINERS — containers restarted on remote after dirty sync
# Was running → restart. Was stopped → leave stopped.
#
# Two-tier rsync enable/disable:
# Tier 1: RSYNC_ENABLED=false → all rsync stops immediately (checked by this script)
# Tier 2: per-orchestrator flag (DAILY_RSYNC_ENABLED etc.) → checked by caller
#
# Bandwidth logging: after each sync, logs profile/duration/status/bytes to
# bandwidth_monitor.sh --log-transfer. Bytes captured from rsync --stats via awk
# using version-stable field names.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Global Rsync Gate
# check_rsync_enabled() — RSYNC_ENABLED=false exits cleanly before any operation.
#
# Partnership Blocklist
# Refuses to sync if REMOTE_SERVER_NAME appears in the partnership blocklist.
# Written at offboard — prevents stale access after a partnership ends.
#
# Version Parity
# check_os_version_parity — refuses sync if servers on incompatible unRAID versions.
#
# Remote Health Pre-flights
# check_connectivity() — Tailscale IP reachable before any SSH
# check_remote_rootfs() — aborts if remote rootfs exceeds ROOTFS_WARN_PCT
# check_remote_share() — aborts if target directory missing or empty on remote
# check_remote_disks() — verifies all backing disks online on remote
#
# Drive Temperature Check
# check_local_disk_temps() — runs before any transfer. Exit 1 = skip this profile,
# exit 2 = abort all remaining syncs (CRITICAL temperature).
#
# Remote Docker Daemon Check
# check_remote_docker_daemon — verified before any container stop/start operations.
# If daemon unresponsive: container operations skipped, rsync proceeds without stopping.
#
# Per-Profile Concurrency Lock
# acquire_rsync_lock() — per-profile lock prevents parallel runs of the same profile.
# Global concurrent limit prevents too many simultaneous rsync processes.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# RSYNC_ENABLED
# Global on/off toggle for all rsync operations. (default: true)
#
# DEFAULT_RSYNC_OPTS
# Base rsync flags for unproiled shares. Note: --delete is intentionally absent —
# media shares spread files only, arr cleanup scripts own deletions. Profile-specific
# opts set --delete explicitly where needed.
#
# BW_LIMIT
# Default bandwidth cap in KB/s when no PROFILE_BW_LIMIT is set. (default: 0 = unlimited)
#
# RETRY_COUNT
# Default retry attempts on rsync failure. (default: 3)
#
# SLEEP
# Default seconds between retry attempts. (default: 60)
#
# ROOTFS_WARN_PCT
# Abort threshold for remote rootfs percentage full. (default: 75)
#
# PROFILES["profile_KEY"]
# Profile definitions — one entry per PROFILE_* key per profile name.
# See OPERATIONAL MODEL above for all supported keys.
#
# BANDWIDTH_LOG / BANDWIDTH_WARN_GB
# Shared with bandwidth_monitor.sh — set once, used by both.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# rsync.sh /path/to/share
# Sync the given path to the remote. Profile inferred from directory basename.
#
# rsync.sh /path/to/share --profile=name
# Sync with explicit profile override — bypasses basename inference.
#
# rsync.sh /path/to/share --dry-run
# Run all pre-flight checks and show what rsync would transfer. No transfer,
# no container stops.
#
# rsync.sh /path/to/share --status
# Show resolved profile, remote identity, and configuration. Then exit.
#
# rsync.sh /path/to/share --log
# Verbose output throughout — every decision logged.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Separate positional directory arg from flags ───────────────────────────────────────────────
DIRECTORY=""
PROFILE_OVERRIDE=""
RAW_ARGS=()
for ARG in "$@"; do
case "$ARG" in
--profile=*) PROFILE_OVERRIDE="${ARG#--profile=}" ;;
--*|*=*) RAW_ARGS+=("$ARG") ;;
*) [[ -z "$DIRECTORY" ]] && DIRECTORY="$ARG" || RAW_ARGS+=("$ARG") ;;
esac
done
parse_args "${RAW_ARGS[@]}"
[[ -z "$DIRECTORY" ]] && {
error "No directory specified"
error "Usage: rsync.sh <dir> [--dry-run] [--log] [--profile=name]"
exit 1
}
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# Tier 1 global gate — Tier 2 (per-orchestrator) checked by caller
if ! check_rsync_enabled; then
warn "RSYNC_ENABLED=false — exiting cleanly"
exit 0
fi
# Blocklist gate — refuse to sync with a partner blocked after offboard
BLOCKLIST_FILE="${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR}/partnership_blocklist.db}"
if [[ -f "$BLOCKLIST_FILE" ]] && grep -q "^${REMOTE_SERVER_NAME}|" "$BLOCKLIST_FILE" 2>/dev/null; then
error "Rsync blocked — $REMOTE_SERVER_NAME is on the partnership blocklist"
error "Re-onboard the partnership to restore access: partnership_manager.sh --onboard"
exit 1
fi
resolve_remote_ip
# ── Profile inference ─────────────────────────────────────────────────────────────────────────
if [[ -n "$PROFILE_OVERRIDE" ]]; then
PROFILE_NAME="$PROFILE_OVERRIDE"
log "Profile override: $PROFILE_NAME"
else
PROFILE_NAME=$(basename "$DIRECTORY" | tr '[:upper:]' '[:lower:]')
log "Profile inferred: $PROFILE_NAME"
fi
# Acquire per-profile lock and check global concurrent limit
acquire_rsync_lock "$PROFILE_NAME"
# Tee all output to a live log file for the Varaverk UI
VV_LIVE_LOG="/tmp/unraid_locks/rsync_${PROFILE_NAME}.log"
VV_LAST_LOG="/tmp/unraid_locks/rsync_${PROFILE_NAME}.last.log"
: > "$VV_LIVE_LOG"
exec 1> >(tee -a "$VV_LIVE_LOG") 2>&1
# ── Load profile settings ─────────────────────────────────────────────────────────────────────
BW_LIMIT=${PROFILE_BW_LIMIT[$PROFILE_NAME]:-$BW_LIMIT}
RETRY_COUNT=${PROFILE_RETRY_COUNT[$PROFILE_NAME]:-$RETRY_COUNT}
SLEEP=${PROFILE_SLEEP[$PROFILE_NAME]:-$SLEEP}
CONTAINER_DELAY=${PROFILE_CONTAINER_DELAY[$PROFILE_NAME]:-$CONTAINER_DELAY}
read -r -a CRITICAL_CONTAINER_NAMES <<< "${PROFILE_CRITICAL_CONTAINER_NAMES[$PROFILE_NAME]:-}"
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[$PROFILE_NAME]:-}"
read -r -a EXCLUDE_DIRS <<< "${PROFILE_EXCLUDE_DIRS[$PROFILE_NAME]:-}"
read -r -a REMOTE_RESTART_CONTAINERS <<< "${PROFILE_REMOTE_RESTART_CONTAINERS[$PROFILE_NAME]:-}"
# Local containers use same names as remote (mirrored naming scheme)
LOCAL_CRITICAL_CONTAINER_NAMES=("${CRITICAL_CONTAINER_NAMES[@]}")
log "$ICON_GEAR Config: profile=${PROFILE_NAME} bw-limit=${BW_LIMIT}KB/s retry=${RETRY_COUNT} sleep=${SLEEP}s container-delay=${CONTAINER_DELAY}s"
log "$ICON_GEAR Containers: critical=${CRITICAL_CONTAINER_NAMES[*]:-none} delayed=${DELAYED_CONTAINERS[*]:-none} remote-restart=${REMOTE_RESTART_CONTAINERS[*]:-none}"
[[ "$SHOW_STATUS" == true ]] && show_status && exit 0
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Pre-flight Checks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
# Disk temp — before touching remote or moving data
# Exit 1 = skip this profile | Exit 2 = abort all remaining profiles
check_local_disk_temps
TEMP_RESULT=$?
if [[ "$TEMP_RESULT" -eq 2 ]]; then
error "Drive temps CRITICAL — aborting all remaining syncs"
exit 2
elif [[ "$TEMP_RESULT" -eq 1 ]]; then
warn "Drive temps high — skipping profile [$PROFILE_NAME]"
exit 1
else
log "Drive temps OK — $TEMP_CHECK_RESULT"
fi
# Version parity — refuse if servers on incompatible unRAID versions
check_os_version_parity || exit 1
check_connectivity
check_remote_rootfs
check_remote_share "$DIRECTORY"
check_remote_disks "$DIRECTORY"
# Remote Docker daemon — check before attempting container operations
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]] || [[ ${#REMOTE_RESTART_CONTAINERS[@]} -gt 0 ]]; then
check_remote_docker_daemon || {
warn "Remote Docker daemon unresponsive — skipping container operations"
warn "Proceeding with rsync only — containers will not be stopped or restarted"
CRITICAL_CONTAINER_NAMES=()
LOCAL_CRITICAL_CONTAINER_NAMES=()
REMOTE_RESTART_CONTAINERS=()
}
fi
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 || ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_STOP $ICON_CONTAINERS Stop Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would stop local: $c"
done
for c in "${CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would stop remote: $c"
done
else
# Local first — flush local databases before pushing
stop_local_containers
# Remote next — prevent writes while receiving
stop_containers
fi
fi
# ==============================================================================================
# ━━━ Transfer ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Transfer ━━━"
echo "$ICON_RUN Source: $DIRECTORY"
echo "$ICON_NET Remote: $REMOTE_SERVER:$DIRECTORY"
echo "$ICON_GEAR Profile: $PROFILE_NAME"
echo "$ICON_HOST Identity: $MY_ID → $REMOTE_ID"
echo ""
get_rsync_opts
# Append profile excludes
for ex in "${EXCLUDE_DIRS[@]:-}"; do
[[ -n "$ex" ]] && RSYNC_OPTS+=(--exclude="$ex")
done
# Add --stats to capture bytes transferred for bandwidth logging
RSYNC_OPTS+=(--stats)
[[ "$DRY_RUN" == true ]] && RSYNC_OPTS+=("--dry-run")
START=$(date +%s)
RSYNC_SUCCESS=false
BYTES_TRANSFERRED=0
ATTEMPT=0
for (( ATTEMPT=1; ATTEMPT<=RETRY_COUNT; ATTEMPT++ )); do
log "$ICON_RETRY Attempt $ATTEMPT of $RETRY_COUNT..."
echo "$ICON_SYNC Rsync running — this may take a while..."
RSYNC_OUTPUT=$(rsync "${RSYNC_OPTS[@]}" \
-e "ssh -i $SSH_KEY -T -o Compression=no -o IPQoS=throughput" \
"$DIRECTORY" "root@${REMOTE_SERVER}:$(dirname "$DIRECTORY")/" 2>&1)
RSYNC_EXIT=$?
if [[ "$RSYNC_EXIT" -eq 0 ]]; then
# Parse bytes transferred from --stats output
BYTES_TRANSFERRED=$(echo "$RSYNC_OUTPUT" | \
awk '/Total transferred file size:/{gsub(/,/,"",$NF); gsub(/[^0-9]/,"",$NF); print $NF+0}')
BYTES_TRANSFERRED="${BYTES_TRANSFERRED:-0}"
log "$ICON_DONE Rsync complete — $BYTES_TRANSFERRED bytes transferred"
RSYNC_SUCCESS=true
break
else
warn "$ICON_RETRY Rsync failed (attempt $ATTEMPT/$RETRY_COUNT)"
log "Exit code: $RSYNC_EXIT"
if [[ "$ATTEMPT" -lt "$RETRY_COUNT" ]]; then
log "Retrying in ${SLEEP}s..."
sleep "$SLEEP"
fi
fi
done
# ==============================================================================================
# ━━━ Start Containers ━━━
# ==============================================================================================
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -gt 0 || ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Start Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
for c in "${CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would start remote: $c"
done
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]:-}"; do
[[ -n "$c" ]] && warn "DRY RUN — would start local: $c"
done
else
# Remote first — can be coming up while local restarts
start_containers
# Local next
start_local_containers
fi
fi
# ==============================================================================================
# ━━━ Remote Restart (dirty sync profiles) ━━━
# ==============================================================================================
# For dirty sync profiles (critical-fallback, emby-fallback) — restart containers on remote
# that were running before sync so they pick up config changes from the dirty sync window.
# Was running → restart. Was stopped → leave stopped.
if [[ ${#REMOTE_RESTART_CONTAINERS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Remote Restart (post dirty sync) ━━━"
log "Restarting configured containers on $REMOTE_SERVER_NAME..."
for container in "${REMOTE_RESTART_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
# Check if container was running before sync (still tracked via RUNNING_CONTAINERS)
WAS_RUNNING=false
for prev in "${RUNNING_CONTAINERS[@]:-}"; do
[[ "$prev" == "$container" ]] && WAS_RUNNING=true && break
done
if [[ "$WAS_RUNNING" == false ]]; then
# Not in stop list — check current remote state
REMOTE_STATUS=$(timeout 15 ssh -i "$SSH_KEY" \
-o ConnectTimeout=10 root@"$REMOTE_SERVER" \
"docker inspect -f '{{.State.Running}}' $container 2>/dev/null" 2>/dev/null)
[[ "$REMOTE_STATUS" != "true" ]] && \
log "$container not running on $REMOTE_SERVER_NAME — skipping remote restart" && \
continue
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $container on $REMOTE_SERVER_NAME"
continue
fi
timeout 15 ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \
"docker restart $container" >/dev/null 2>&1 && \
log "$ICON_STARTED $container restarted on $REMOTE_SERVER_NAME ✅" || \
warn "Failed to restart $container on $REMOTE_SERVER_NAME"
done
fi
END=$(date +%s)
DURATION=$(( END - START ))
# ==============================================================================================
# ━━━ Bandwidth Logging ━━━
# ==============================================================================================
# Logs to bandwidth_monitor.sh — new format includes bytes transferred and warn flag.
# Only logs on actual runs (not dry-run) and only when bandwidth_monitor.sh exists.
BANDWIDTH_MONITOR="$SCRIPT_DIR/../Monitors/bandwidth_monitor.sh"
if [[ "$DRY_RUN" == false ]] && [[ -f "$BANDWIDTH_MONITOR" ]]; then
STATUS="success"
[[ "$RSYNC_SUCCESS" == false ]] && STATUS="failed"
bash "$BANDWIDTH_MONITOR" --log-transfer \
"$PROFILE_NAME" "$DURATION" "$STATUS" "$BYTES_TRANSFERRED"
log "$ICON_BANDWIDTH Transfer logged to bandwidth monitor ($BYTES_TRANSFERRED bytes)"
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RSYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_RUN Directory: $DIRECTORY"
echo "$ICON_GEAR Profile: $PROFILE_NAME"
echo "$ICON_TIME Duration: $(format_duration $DURATION)"
[[ "$BYTES_TRANSFERRED" -gt 0 ]] && \
echo "$ICON_BANDWIDTH Transferred: $(awk "BEGIN {printf \"%.2fGB\", $BYTES_TRANSFERRED / 1073741824}")"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$RSYNC_SUCCESS" == true ]]; then
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
else
echo "$ICON_ERROR Status: FAILED after $RETRY_COUNT attempts"
notify "Rsync FAILED — $DIRECTORY ($PROFILE_NAME) after $RETRY_COUNT attempts on $(hostname)" \
"Rsync" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
# Flush tee and preserve log for UI — close both ends of the pipe so tee gets EOF
exec 1>&- 2>&-; wait
cp "$VV_LIVE_LOG" "$VV_LAST_LOG" 2>/dev/null
rm -f "$VV_LIVE_LOG"
[[ "$RSYNC_SUCCESS" == false ]] && [[ "$DRY_RUN" == false ]] && exit 1
exit 0
@@ -0,0 +1,195 @@
#!/bin/bash
# ==============================================================================================
# ================================= Mover Stop =================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Safely stops the unRAID mover with a wall warning, configurable timeout, and
# SIGTERM → SIGKILL sequence. Use before planned reboots, disk operations, or
# any operation where mover and rsync running simultaneously could corrupt files.
# Exits cleanly if mover is not running.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Stop Sequence
# 1. Check if mover is running — exit cleanly if not
# 2. Wall message to all logged-in terminal users
# 3. Wait MOVER_STOP_TIMEOUT seconds
# 4. SIGTERM — allows mover to finish its current file before stopping
# (no partial files — the mover completes what it is working on)
# 5. Wait 5 seconds → verify stopped
# 6. SIGKILL if still running — forced stop, partial files possible
# 7. Final verify — error if still running after SIGKILL
#
# SIGTERM first because the mover has an opportunity to finish the file it is
# currently moving, leaving no partial copies on cache or array. SIGKILL is only
# used as a last resort and may leave a file split across cache and array.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents concurrent stop attempts racing each other.
#
# Root Required
# pkill on emhttp processes requires root.
#
# Final Verify
# Confirms mover is actually stopped after the kill sequence — errors if it
# is still running after SIGKILL.
#
# Silent When Clean
# Mover not running = log() only, no visible output.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# MOVER_STOP_TIMEOUT
# Seconds between wall warning and SIGTERM. (default: 30)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# mover_stop.sh
# Check if mover is running. If so, warn users and stop it.
#
# mover_stop.sh --dry-run
# Show mover state and what would happen. No signals sent.
#
# mover_stop.sh --status
# Show mover state (running, PID, start time). Then exit.
#
# mover_stop.sh --log
# Verbose output showing each step of the stop sequence.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — pkill on emhttp processes requires root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
validate_int MOVER_STOP_TIMEOUT "$MOVER_STOP_TIMEOUT"
acquire_lock
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_MOVER Timeout: ${MOVER_STOP_TIMEOUT}s"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo ""
if platform_is_mover_running; then
MOVER_PID=$(platform_get_mover_pid)
MOVER_START=$(ps -o lstart= -p "$MOVER_PID" 2>/dev/null | xargs)
echo " $ICON_MOVER Mover: RUNNING (PID $MOVER_PID)"
[[ -n "$MOVER_START" ]] && echo " $ICON_TIME Started: $MOVER_START"
else
echo " $ICON_MOVER Mover: not running"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Mover Stop ━━━
# ==============================================================================================
START=$(date +%s)
if ! platform_is_mover_running; then
echo "Mover is not running — nothing to do"
exit 0
fi
MOVER_PID=$(platform_get_mover_pid)
MOVER_START=$(ps -o lstart= -p "$MOVER_PID" 2>/dev/null | xargs)
MOVER_ELAPSED=$(ps -o etimes= -p "$MOVER_PID" 2>/dev/null | tr -d ' ')
warn "Mover is running (PID $MOVER_PID) — stopping in ${MOVER_STOP_TIMEOUT}s"
log "$ICON_TIME Mover started: ${MOVER_START:-unknown} — running for $(format_duration "${MOVER_ELAPSED:-0}")"
# ── Warn users via wall ───────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == false ]]; then
wall "$ICON_WARN $MY_ID ($LOCAL_SERVER_NAME) — unRAID Mover stopping in ${MOVER_STOP_TIMEOUT}s"
echo "Wall message sent — waiting ${MOVER_STOP_TIMEOUT}s..."
sleep "$MOVER_STOP_TIMEOUT"
else
warn "DRY RUN — would send wall warning and wait ${MOVER_STOP_TIMEOUT}s"
fi
# ── SIGTERM — graceful stop ───────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would send SIGTERM to mover (PID $MOVER_PID)"
else
log "Sending SIGTERM to mover (PID $MOVER_PID)..."
kill -TERM "$MOVER_PID" 2>/dev/null || true
sleep 5
# Verify stopped after SIGTERM
if ! platform_is_mover_running; then
warn "Mover stopped cleanly (SIGTERM) ✅"
else
# ── SIGKILL — forced stop ─────────────────────────────────────────────────────────────
warn "Mover still running after SIGTERM — sending SIGKILL (may leave partial files)"
kill -KILL "$MOVER_PID" 2>/dev/null || true
sleep 2
# Final verify
if platform_is_mover_running; then
error "Mover still running after SIGKILL — manual intervention needed"
notify "Mover stop failed on $(hostname) ($MY_ID) — process unkillable" \
"Mover Stop" "warning"
exit 1
else
warn "Mover force-stopped (SIGKILL) — check for partial files on cache"
fi
fi
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY MOVER STOP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_MOVER Timeout: ${MOVER_STOP_TIMEOUT}s"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
else
echo "$ICON_DONE Status: done — mover stopped ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,405 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# WEEKLY_RESTART_CONTAINERS — restarted by docker_weekly_restart.sh after sync
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Weekly restart containers — restarted by docker_weekly_restart.sh after sync
for _c in "${WEEKLY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, weekly restart, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,413 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# WEEKLY_RESTART_CONTAINERS — restarted by docker_weekly_restart.sh after sync
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# WEEKLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
if [[ "${WEEKLY_REMAINING_UPDATES:-true}" != "true" ]]; then
echo "WEEKLY_REMAINING_UPDATES=false — skipping remainder container updates"
exit 0
fi
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Weekly restart containers — restarted by docker_weekly_restart.sh after sync
for _c in "${WEEKLY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, weekly restart, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,415 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# WEEKLY_RESTART_CONTAINERS — restarted by docker_weekly_restart.sh after sync
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# WEEKLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
# Called by daily_sync_maintenance.sh before docker_daily_restart.sh
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
# Called by weekly_sync_maintenance.sh at the end of the Sunday window
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
if [[ "${WEEKLY_REMAINING_UPDATES:-true}" != "true" ]]; then
echo "WEEKLY_REMAINING_UPDATES=false — skipping remainder container updates"
exit 0
fi
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Weekly restart containers — restarted by docker_weekly_restart.sh after sync
for _c in "${WEEKLY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, weekly restart, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,411 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# WEEKLY_RESTART_CONTAINERS are included — docker_weekly_restart.sh restarts
# them but doesn't pull images; remainder is the only place they get updated.
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# WEEKLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
# Called by daily_sync_maintenance.sh before docker_daily_restart.sh
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
# Called by weekly_sync_maintenance.sh at the end of the Sunday window
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
if [[ "${WEEKLY_REMAINING_UPDATES:-true}" != "true" ]]; then
echo "WEEKLY_REMAINING_UPDATES=false — skipping remainder container updates"
exit 0
fi
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,411 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Two modes: normal (daily)
# and remainder (weekly).
#
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
# It catches everything that normal mode and the weekly sync window did not already
# update — derived automatically from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (weekly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# emby + critical-data profiles — updated inline by the weekly sync window
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# WEEKLY_RESTART_CONTAINERS are included — docker_weekly_restart.sh restarts
# them but doesn't pull images; remainder is the only place they get updated.
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES Toggle
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
# regardless — update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# MONTHLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
# Called by daily_sync_maintenance.sh before docker_daily_restart.sh
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
# Called by monthly_maintenance.sh
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
_filtered_args=()
for _arg in "$@"; do
if [[ "$_arg" == "--remainder" ]]; then
REMAINDER_MODE=true
else
_filtered_args+=("$_arg")
fi
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
if [[ "${MONTHLY_REMAINING_UPDATES:-true}" != "true" ]]; then
echo "MONTHLY_REMAINING_UPDATES=false — skipping remainder container updates"
exit 0
fi
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, and fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
else
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,458 @@
#!/bin/bash
# ==============================================================================================
# ================================= Docker Update ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest images for configured containers. Three modes:
#
# Normal mode — called by daily_sync_maintenance.sh before docker_daily_restart.sh.
# Containers stay running during the pull — no extra downtime beyond what the
# nightly restart already causes.
#
# Weekly mode — called by weekly_sync_maintenance.sh via WEEKLY_MAINTENANCE_SCRIPTS
# before docker_weekly_restart.sh. Pulls WEEKLY_RESTART_CONTAINERS so the
# subsequent restart lands on the fresh image.
#
# Remainder mode — called by monthly_maintenance.sh. Catches everything not
# already owned by daily or weekly — derived from docker ps, nothing to configure.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Normal mode (daily):
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
# Pull → compare old vs new image ID → mark updated or already current.
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
#
# Weekly mode:
# Targets WEEKLY_RESTART_CONTAINERS — same list used by docker_weekly_restart.sh.
# Pull → compare → rebuild if changed.
# docker_weekly_restart.sh runs after — containers restart onto the fresh image.
#
# Remainder mode (monthly):
# Targets all currently running containers NOT in:
# DAILY_RESTART_CONTAINERS — already updated daily
# WEEKLY_RESTART_CONTAINERS — already updated weekly
# emby + critical-data profiles — updated inline by the weekly sync window
# FALLBACK_*_TIER* — owned by the remote server's update cycle
# Pull → compare → prune dangling images.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Single List
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
# separate update list. Adding or removing a container from the restart list
# automatically updates the image pull list — one change, both places.
#
# Version Ownership
# Fallback containers are excluded from remainder mode. This server only runs
# them during a fallback. The remote server owns their version — if remainder
# updates them independently and a handback occurs, the remote's older image
# may not handle data written by the newer version.
#
# State Respect
# Stopped containers are never targeted. Pulling while stopped adds no value
# and a stopped container was likely halted intentionally.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Lock Acquisition
# Prevents concurrent execution via acquire_lock(). Safe to call from
# maintenance scripts without risk of overlap.
#
# Host Detection
# detect_hosts() identifies which server is running the script and aliases
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
#
# Root Enforcement
# Docker operations require root privileges.
#
# DAILY_CONTAINER_UPDATES / WEEKLY_CONTAINER_UPDATES Toggles
# Each mode exits cleanly when disabled. Restart scripts run regardless —
# update and restart are independent operations.
#
# Fallback Exclusion
# Remainder mode excludes containers owned by the remote server's update cycle
# to prevent version divergence across the fallback boundary.
#
# Running-Only Filter
# Stopped containers excluded from remainder mode — intentionally down.
#
# Image ID Comparison
# Containers not restarted unless their image actually changed. Pulls that
# result in "already up to date" produce no restart.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# DAILY_CONTAINER_UPDATES
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
# (default: true)
#
# WEEKLY_CONTAINER_UPDATES
# Enable or disable weekly mode. docker_weekly_restart.sh runs regardless.
# (default: true)
#
# MONTHLY_REMAINING_UPDATES
# Enable or disable remainder mode. (default: true)
#
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
# Container names for emby and critical-data profiles — excluded from
# remainder mode (already updated by the weekly sync window)
#
# host*.conf
#
# HOST*_DAILY_RESTART_CONTAINERS
# Containers updated in normal mode. Aliased by detect_hosts() →
# DAILY_RESTART_CONTAINERS
#
# HOST*_WEEKLY_RESTART_CONTAINERS
# Containers updated in weekly mode. Aliased by detect_hosts() →
# WEEKLY_RESTART_CONTAINERS
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_update.sh
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
# Called by daily_sync_maintenance.sh before docker_daily_restart.sh
#
# docker_update.sh --weekly
# Weekly mode — pull latest images for WEEKLY_RESTART_CONTAINERS
# Called by weekly_sync_maintenance.sh (WEEKLY_MAINTENANCE_SCRIPTS) before docker_weekly_restart.sh
#
# docker_update.sh --remainder
# Remainder mode — pull all running containers not in managed lists,
# restart those that received updates, prune dangling images
# Called by monthly_maintenance.sh
#
# docker_update.sh --dry-run
# Preview which containers would be pulled without making changes
#
# docker_update.sh --status
# Show configuration and container list for current mode
#
# docker_update.sh --log
# Verbose per-container pull and comparison output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# Pre-parse mode flags before parse_args (unknown args pass through to PARSED_ARGS)
REMAINDER_MODE=false
WEEKLY_MODE=false
_filtered_args=()
for _arg in "$@"; do
case "$_arg" in
--remainder) REMAINDER_MODE=true ;;
--weekly) WEEKLY_MODE=true ;;
*) _filtered_args+=("$_arg") ;;
esac
done
unset _arg
parse_args "${_filtered_args[@]}"
unset _filtered_args
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# ==============================================================================================
# ━━━ Container Discovery ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
if [[ "${MONTHLY_REMAINING_UPDATES:-true}" != "true" ]]; then
echo "MONTHLY_REMAINING_UPDATES=false — skipping remainder container updates"
exit 0
fi
declare -A _exclude=()
# Daily containers — updated by docker_update.sh normal mode
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly containers — updated by docker_update.sh --weekly
for _c in "${WEEKLY_RESTART_CONTAINERS[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
read -r -a _weekly_arr <<< "$_weekly_str"
for _c in "${_weekly_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
unset _weekly_str _weekly_arr
# Fallback coverage containers — owned by the remote server's update cycle.
# This server runs them during fallback but should never update them independently.
# Updating them here risks version divergence: if remote's writeback after handback
# encounters data written by a newer version, it may not handle it correctly.
for _tier in 1 2 3 4; do
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
for _c in "${_tier_arr[@]}"; do
[[ -n "$_c" ]] && _exclude["$_c"]=1
done
done
unset _tier _tier_var _tier_arr _c
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
TARGET_CONTAINERS=()
for _c in "${_all_running[@]}"; do
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
done
unset _all_running _exclude _c
elif [[ "$WEEKLY_MODE" == true ]]; then
if [[ "${WEEKLY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "WEEKLY_CONTAINER_UPDATES=false — skipping weekly container updates"
exit 0
fi
if [[ ${#WEEKLY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "WEEKLY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_WEEKLY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${WEEKLY_RESTART_CONTAINERS[@]}")
else
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
exit 0
fi
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
exit 0
fi
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
fi
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
if [[ "$REMAINDER_MODE" == true ]]; then
echo "$ICON_GEAR Mode: remainder (monthly)"
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly, sync-window, fallback)"
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
elif [[ "$WEEKLY_MODE" == true ]]; then
echo "$ICON_GEAR Mode: weekly"
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${WEEKLY_CONTAINER_UPDATES:-true}"
else
echo "$ICON_GEAR Mode: normal (daily)"
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
fi
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
echo "No containers to update"
exit 0
fi
# ==============================================================================================
# ━━━ Pull Updates ━━━
# ==============================================================================================
echo ""
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily, weekly, or sync window)"
log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}"
elif [[ "$WEEKLY_MODE" == true ]]; then
echo "━━━ $ICON_CONTAINERS Docker Update (weekly) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
else
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
fi
echo ""
START=$(date +%s)
UPDATED=()
UP_TO_DATE=()
FAILED=()
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
SKIPPED=()
for container in "${TARGET_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
log "━━━ $ICON_CONTAINERS $container ━━━"
if ! docker inspect "$container" &>/dev/null; then
warn "$container — not found, skipping"
SKIPPED+=("$container")
continue
fi
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
warn "$container — could not determine image, skipping"
SKIPPED+=("$container")
continue
fi
log "$container — image: $IMAGE"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull: $IMAGE"
UPDATED+=("$container")
continue
fi
# Capture the image ID the container is currently running on, and the
# image ID :latest points to before the pull. After pulling, we rebuild if
# either a new digest landed OR the container is behind what :latest is now.
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "$ICON_SYNC Pulling $IMAGE..."
if [[ "$ENABLE_LOGGING" == "true" ]]; then
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
_pull_rc=${PIPESTATUS[0]}
else
docker pull "$IMAGE" >/dev/null 2>&1
_pull_rc=$?
fi
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ $_pull_rc -eq 0 ]]; then
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
UPDATED+=("$container")
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
else
log "$container — up to date (${NEW_ID:7:12})"
UP_TO_DATE+=("$container")
fi
else
warn "$container — pull failed ($IMAGE)"
FAILED+=("$container")
fi
done
# ── Recreate containers that received a new image ────────────────────────────
# docker restart uses the image ID baked in at creation time — it never picks
# up the new digest. rebuild_container reads the stored XML template, stops the
# old container, recreates it (new image, same config), then prunes the old image.
REBUILT=()
REBUILD_FAILED=()
if [[ ${#UPDATED[@]} -gt 0 ]]; then
for container in "${UPDATED[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would rebuild $container from template"
REBUILT+=("$container")
continue
fi
log "$ICON_SYNC Rebuilding $container from template on new image..."
if platform_rebuild_container "$container"; then
log "$ICON_DONE $container rebuilt ✅"
REBUILT+=("$container")
else
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
REBUILD_FAILED+=("$container")
fi
done
fi
# ── Remove old images ────────────────────────────────────────────────────────
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
# caught by dangling-only prune, so this is the only reliable cleanup path.
# Fall through to dangling prune to catch any leftovers from other update paths.
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
PRUNED_SUMMARY="(dry run)"
else
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
docker rmi "$_old_id" >/dev/null 2>&1 || true
done
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
if [[ "$REMAINDER_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
elif [[ "$WEEKLY_MODE" == true ]]; then
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (WEEKLY) SUMMARY ━━━━━"
else
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
fi
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
if [[ ${#UPDATED[@]} -gt 0 ]]; then
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
log " ${UPDATED[*]}"
fi
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${UP_TO_DATE[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no images pulled"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
else
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Pull failures are non-fatal — restart proceeds regardless
exit 0
@@ -0,0 +1,313 @@
#!/bin/bash
# ==============================================================================================
# =============================== Emby → Lidarr Sync ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# One-shot tool. Finds artists played on Emby that are not tracked in Lidarr
# and adds them. No scoring — if you played it, Lidarr should monitor it.
#
# Intended as a bootstrap / catch-up tool, not a scheduled script. Run it once
# after Lidarr is set up, or any time you suspect gaps between what you listen
# to and what Lidarr monitors.
#
# ==============================================================================================
# FLOW
# ==============================================================================================
#
# 1. Pull play completions from Emby activity log (all time, or --days N)
# 2. Fetch current Lidarr artist library
# 3. For each played artist not in Lidarr → add to Lidarr
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# acquire_lock requires root. Script exits cleanly if not root.
#
# Dry-Run Mode
# --dry-run shows all artists that would be added without making any Lidarr API calls.
# Always run first when closing the gap after a fresh Lidarr install or database wipe.
#
# Add-Only
# Only adds artists to Lidarr. Items already tracked (by name) are skipped without
# modification. Safe to run multiple times — the second run finds nothing to add.
#
# ==============================================================================================
# CONFIGURATION (host*.conf)
# ==============================================================================================
#
# HOST*_LIDARR_URL / HOST*_LIDARR_API_KEY — Lidarr connection (aliased by detect_hosts)
# HOST*_EMBY_URL / HOST*_EMBY_API_KEY — Emby connection (aliased by detect_hosts)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# emby_to_lidarr_sync.sh — add all album artists not in Lidarr
# emby_to_lidarr_sync.sh --dry-run — show what would be added, no changes
# emby_to_lidarr_sync.sh --log — verbose output
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
detect_hosts
if ! command -v jq >/dev/null 2>&1; then
error "jq not installed"
exit 1
fi
if [[ -z "${EMBY_URL:-}" || -z "${EMBY_API_KEY:-}" ]]; then
error "EMBY_URL / EMBY_API_KEY not configured — check host*.conf"
exit 1
fi
if [[ -z "${LIDARR_URL:-}" || -z "${LIDARR_API_KEY:-}" ]]; then
error "LIDARR_URL / LIDARR_API_KEY not configured — check host*.conf"
exit 1
fi
log "$ICON_GEAR Config: emby=${EMBY_URL} lidarr=${LIDARR_URL}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added to Lidarr"
# ==============================================================================================
# ── API HELPERS ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_lidarr_get() {
curl -sf --max-time 30 \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/${1}" 2>/dev/null
}
_lidarr_lookup() {
curl -sf --max-time 20 --get \
--data-urlencode "term=$1" \
-H "X-Api-Key: $LIDARR_API_KEY" \
"${LIDARR_URL}/api/v1/artist/lookup" 2>/dev/null
}
_lidarr_post() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${LIDARR_URL}/api/v1/artist" 2>/dev/null
}
_lidarr_command() {
curl -sf --max-time 20 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$1" \
"${LIDARR_URL}/api/v1/command" 2>/dev/null
}
_is_placeholder_artist() {
local a="${1,,}"
[[ "$a" =~ ^(va|various|various artists|unknown artist|unknown|soundtrack|original soundtrack|ost)$ ]]
}
# Dirty tag: comma-list, feat./ft., ampersand join, or "Artist - Album" in the AlbumArtist field
_is_dirty_artist() {
local lower="${1,,}"
[[ "$1" == *", "* ]] && return 0
[[ "$lower" == *" feat."* ]] && return 0
[[ "$lower" == *" ft."* ]] && return 0
[[ "$1" == *" & "* ]] && return 0
[[ "$lower" == *" vs "* ]] && return 0
[[ "$1" == *" - "* ]] && return 0
[[ "$1" == *"?"* ]] && return 0 # ASCII ? — encoding corruption
[[ "$1" == *$'\xef\xbf\xbd'* ]] && return 0 # U+FFFD replacement character — encoding corruption
return 1
}
# ==============================================================================================
# ━━━ Fetch Emby Music Artist Library ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY Emby → Lidarr Sync — $(date '+%Y-%m-%d %H:%M:%S') ━━━━━"
echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME)"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be added"
echo ""
echo "━━━ $ICON_SYNC Emby Music Library ━━━"
# Query MusicAlbum items and extract AlbumArtists — gives primary album artists only,
# not the full tag credits (guest features, songwriters, etc.) that MusicArtist returns
EMBY_ALBUMS_JSON=$(emby_api "Items?IncludeItemTypes=MusicAlbum&Recursive=true&Fields=AlbumArtists&Limit=10000") || {
error "Could not fetch Emby music albums"
exit 1
}
declare -A PLAYED_ARTISTS
while IFS= read -r artist; do
[[ -z "$artist" || "$artist" == "null" ]] && continue
_is_placeholder_artist "$artist" && continue
_is_dirty_artist "$artist" && { log " $ICON_SKIP Skipping dirty tag: $artist"; continue; }
PLAYED_ARTISTS["$artist"]=1
done < <(echo "$EMBY_ALBUMS_JSON" | jq -r '.Items[] | .AlbumArtists[]?.Name' 2>/dev/null)
PLAYED_COUNT=${#PLAYED_ARTISTS[@]}
log "$PLAYED_COUNT album artists in Emby library"
if [[ "$PLAYED_COUNT" -eq 0 ]]; then
warn "No album artists found in Emby library"
exit 0
fi
# ==============================================================================================
# ━━━ Fetch Lidarr Library ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Lidarr Library ━━━"
LIDARR_ARTISTS_JSON=$(_lidarr_get "artist") || { error "Could not fetch Lidarr artists"; exit 1; }
LIDARR_NAMES=$(echo "$LIDARR_ARTISTS_JSON" | jq -r '.[].artistName' 2>/dev/null)
LIDARR_COUNT=$(echo "$LIDARR_NAMES" | grep -c . 2>/dev/null || echo 0)
log "$LIDARR_COUNT artists already in Lidarr"
_in_lidarr() {
echo "$LIDARR_NAMES" | grep -iq "^${1}$"
}
# ==============================================================================================
# ━━━ Find Gaps ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Comparing Libraries ━━━"
MISSING=()
ALREADY=0
for artist in "${!PLAYED_ARTISTS[@]}"; do
if _in_lidarr "$artist"; then
(( ALREADY++ ))
log " $ICON_SKIP Already tracked: $artist"
else
MISSING+=("$artist")
log " $ICON_WARN Not in Lidarr: $artist"
fi
done
IFS=$'\n' MISSING=($(printf '%s\n' "${MISSING[@]}" | sort))
echo " Already tracked: $ALREADY | Missing from Lidarr: ${#MISSING[@]}"
if [[ "${#MISSING[@]}" -eq 0 ]]; then
echo "Lidarr already tracks everything played on Emby"
exit 0
fi
echo ""
echo "━━━ $ICON_SUMMARY Artists to add (${#MISSING[@]}) ━━━"
for a in "${MISSING[@]}"; do log " $a"; done
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN complete — run without --dry-run to add these artists"
exit 0
fi
# ==============================================================================================
# ━━━ Add to Lidarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Adding to Lidarr ━━━"
LIDARR_ROOT=$(_lidarr_get "rootfolder" | jq -r 'first(.[] | select(.accessible == true)) | .path' 2>/dev/null)
if [[ -z "$LIDARR_ROOT" ]]; then error "Could not determine Lidarr root folder"; exit 1; fi
QUALITY_PROFILES=$(_lidarr_get "qualityprofile") || { error "Could not fetch quality profiles"; exit 1; }
METADATA_PROFILES=$(_lidarr_get "metadataprofile") || { error "Could not fetch metadata profiles"; exit 1; }
DEFAULT_QUALITY_ID=$(echo "$QUALITY_PROFILES" | jq -r '.[0].id' 2>/dev/null)
DEFAULT_METADATA_ID=$(echo "$METADATA_PROFILES" | jq -r '
first(.[] | select(.name | test("Standard"; "i")) | .id) // .[0].id' 2>/dev/null)
log "Quality profile: $DEFAULT_QUALITY_ID | Metadata profile: $DEFAULT_METADATA_ID"
ADDED=0
FAILED=0
SKIPPED=0
for artist in "${MISSING[@]}"; do
LOOKUP=$(_lidarr_lookup "$artist")
if [[ -z "$LOOKUP" ]] || echo "$LOOKUP" | jq -e '. == [] or . == null' >/dev/null 2>&1; then
warn " $ICON_WARN No match in Lidarr lookup: $artist"
(( FAILED++ ))
continue
fi
ARTIST_DATA=$(echo "$LOOKUP" | jq '.[0]' 2>/dev/null)
MBID=$(echo "$ARTIST_DATA" | jq -r '.foreignArtistId // ""' 2>/dev/null)
LIDARR_NAME=$(echo "$ARTIST_DATA" | jq -r '.artistName // ""' 2>/dev/null)
if [[ -z "$MBID" ]]; then
warn " $ICON_WARN No MusicBrainz ID for: $artist"
(( FAILED++ ))
continue
fi
PAYLOAD=$(echo "$ARTIST_DATA" | jq \
--arg root "$LIDARR_ROOT" \
--argjson qid "$DEFAULT_QUALITY_ID" \
--argjson mid "$DEFAULT_METADATA_ID" \
'. + {
rootFolderPath: $root,
qualityProfileId: $qid,
metadataProfileId: $mid,
monitored: true,
addOptions: {
monitor: "all",
searchForMissingAlbums: false
}
}' 2>/dev/null)
RESULT=$(_lidarr_post "$PAYLOAD")
if echo "$RESULT" | jq -e '.id' >/dev/null 2>&1; then
ARTIST_ID=$(echo "$RESULT" | jq -r '.id')
_lidarr_command "{\"name\":\"ArtistSearch\",\"artistId\":${ARTIST_ID}}" >/dev/null
log " $ICON_DONE Added: $LIDARR_NAME"
(( ADDED++ ))
else
warn " $ICON_WARN Failed to add: $artist"
log " $(echo "$RESULT" | head -c 200)"
(( FAILED++ ))
fi
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SYNC COMPLETE ━━━━━"
echo " $ICON_DONE Added: $ADDED"
[[ "$FAILED" -gt 0 ]] && echo " $ICON_WARN Failed: $FAILED"
echo " $ICON_SKIP Already tracked: $ALREADY"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$ADDED" -gt 0 ]] && notify \
"$ADDED artist(s) added to Lidarr from Emby play history on $(hostname)" \
"Emby → Lidarr Sync" "normal"
exit 0
@@ -0,0 +1,400 @@
#!/bin/bash
# ==============================================================================================
# ============================= Weekly Sync Maintenance ========================================
# ==============================================================================================
# Weekly maintenance window orchestrator — clean sync, container updates, weekly restarts.
# Schedule: 30 2 * * 0 (Sunday 2:30am — before Sunday 7am coffee report)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. Stop local containers — Emby + auth stack stopped locally
# 2. Stop remote containers — Emby + auth stack stopped remotely via SSH
# 3. Pull updates locally — if WEEKLY_SYNC_UPDATES=true (zero extra downtime)
# 4. Pull updates remotely — if WEEKLY_SYNC_UPDATES_REMOTE=true
# 5. rsync WEEKLY_SYNC_SHARES — full clean mirror, containers stopped both sides
# 6. Start remote containers — correct order, delayed start respected
# 7. Start local containers — correct order, delayed start respected
# 8. WEEKLY_MAINTENANCE_SCRIPTS — weekly restarts etc. (docker_weekly_restart.sh)
# 9. docker_update.sh --remainder — update all containers not in daily or weekly sync window
#
# ── WHY WEEKLY NOT NIGHTLY FOR EMBY ──────────────────────────────────────────────────────────
# Emby builds a warm image cache on HOST2 throughout the week.
# Syncing nightly resets cache — cold loads every morning for users.
# Weekly sync: cache stays warm 6 days, resets Sunday night while users sleep.
# emby-fallback dirty sync covers watch states + library every 30min between weekly syncs.
#
# ── CONTAINER UPDATES ─────────────────────────────────────────────────────────────────────────
# Containers already stopped for sync — updates pull at zero extra downtime.
# Both servers start on identical image versions after the window completes.
# Toggle: WEEKLY_SYNC_UPDATES / WEEKLY_SYNC_UPDATES_REMOTE in master.conf
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID — used in banner, summary, and notifications.
# WEEKLY_SYNC_SHARES and WEEKLY_MAINTENANCE_SCRIPTS configured in master.conf.
# Same script runs correctly on both servers.
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — stop/start containers, rsync require root
# acquire_lock — prevents concurrent weekly windows
# check_connectivity — verifies remote before any remote operations
# check_remote_rootfs — aborts if remote rootfs nearly full
# DOCKER_TIMEOUT — all docker calls protected
# SSH_TIMEOUT — all SSH calls protected
# platform_require_cmd — notify validated before use
# Silent on success — runs weekly, only failures warrant notification
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# WEEKLY_SYNC_SHARES — shares synced during window
# WEEKLY_MAINTENANCE_SCRIPTS — scripts run after sync
# WEEKLY_SYNC_UPDATES — toggle local container updates
# WEEKLY_SYNC_UPDATES_REMOTE — toggle remote container updates
# WEEKLY_RSYNC_ENABLED — enable/disable rsync section
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# weekly_sync_maintenance.sh — normal run
# weekly_sync_maintenance.sh --dry-run — preview without stopping containers or syncing
# weekly_sync_maintenance.sh --log — verbose per-share/per-job output
# weekly_sync_maintenance.sh --status — show configuration and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
DOCKER_TIMEOUT=30 # container stop/start needs longer than normal
SSH_TIMEOUT=30 # remote pulls can be slow
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
resolve_remote_ip
WINDOW_START=$(date +%s)
PASS=()
FAIL=()
JOB_PASS=()
JOB_FAIL=()
# Load container lists from profile config
read -r -a MAINTENANCE_CONTAINERS <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped, no sync, no updates"
# ── Helper — run a post-sync maintenance script ────────────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Weekly enabled: ${WEEKLY_RSYNC_ENABLED:-false}"
echo "$ICON_GEAR Local updates: ${WEEKLY_SYNC_UPDATES:-false}"
echo "$ICON_GEAR Remote updates: ${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "━━━ Weekly Sync Shares ━━━"
if [[ ${#WEEKLY_SYNC_SHARES[@]} -eq 0 ]]; then
warn " No WEEKLY_SYNC_SHARES configured"
else
for share in "${WEEKLY_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Weekly Maintenance Scripts ━━━"
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo ""
echo "━━━ Containers (from profile config) ━━━"
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && echo " $ICON_CONTAINERS $c"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight Checks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Weekly Sync Maintenance — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
check_connectivity
check_remote_rootfs
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP $ICON_CONTAINERS Stop Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be stopped"
else
# Load container lists for stop functions
read -r -a CRITICAL_CONTAINER_NAMES <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-}"
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[critical-data]:-}"
CONTAINER_DELAY="${PROFILE_CONTAINER_DELAY[critical-data]:-15}"
stop_local_containers
stop_containers
fi
# ==============================================================================================
# ━━━ Container Updates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Container Updates ━━━"
if [[ "$WEEKLY_SYNC_UPDATES" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && warn "DRY RUN — would pull: $c"
done
else
log "Pulling local container updates..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$DOCKER_TIMEOUT" docker inspect \
"$c" --format '{{.Config.Image}}' 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found locally, skipping update"
continue
fi
log "Pulling $IMAGE for $c..."
if docker pull "$IMAGE" >/dev/null 2>&1; then
log "$c — image updated ✅"
else
warn "$c — pull failed, will start on existing image"
fi
done
fi
else
echo "WEEKLY_SYNC_UPDATES=false — skipping local updates"
fi
if [[ "$WEEKLY_SYNC_UPDATES_REMOTE" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull updates on $REMOTE_SERVER_NAME"
else
log "Pulling remote container updates on $REMOTE_SERVER_NAME..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker inspect $c --format '{{.Config.Image}}' 2>/dev/null" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found on remote, skipping update"
continue
fi
log "Pulling $IMAGE for $c on $REMOTE_SERVER_NAME..."
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker pull $IMAGE" >/dev/null 2>&1; then
log "$c — remote image updated ✅"
else
warn "$c — remote pull failed, will start on existing image"
fi
done
fi
else
echo "WEEKLY_SYNC_UPDATES_REMOTE=false — skipping remote updates"
fi
# ==============================================================================================
# ━━━ Critical Shares Sync ━━━
# ==============================================================================================
SHARE_COUNT=${#WEEKLY_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Critical Shares Sync — $SHARE_COUNT share(s) ━━━"
SYNC_START=$(date +%s)
JOB_NUM=0
if ! check_rsync_enabled "WEEKLY"; then
warn "Weekly rsync disabled — skipping all $SHARE_COUNT sync job(s)"
warn "Proceeding to container start and maintenance scripts..."
elif [[ "$SHARE_COUNT" -eq 0 ]]; then
warn "No WEEKLY_SYNC_SHARES configured — skipping sync"
warn "Check WEEKLY_SYNC_SHARES in master.conf"
else
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for JOB in "${WEEKLY_SYNC_SHARES[@]}"; do
(( JOB_NUM++ ))
JOB_NAME=$(basename "$JOB")
echo ""
echo "━━━ [$JOB_NUM/$SHARE_COUNT] $JOB_NAME ━━━"
JOB_START=$(date +%s)
bash "$RSYNC_SCRIPT" "$JOB" $RSYNC_DRY
EXIT_CODE=$?
JOB_DUR=$(format_duration $(( $(date +%s) - JOB_START )))
if [[ "$EXIT_CODE" -eq 0 ]]; then
PASS+=("$JOB_NAME")
log "$JOB_NAME — done in $JOB_DUR ✅"
else
FAIL+=("$JOB_NAME")
error "$JOB_NAME — failed after $JOB_DUR (exit $EXIT_CODE)"
fi
echo ""
done
fi
SYNC_END=$(date +%s)
# ==============================================================================================
# ━━━ Start Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Start Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be started"
else
start_containers
start_local_containers
fi
# ==============================================================================================
# ━━━ Post-sync Jobs ━━━
# ==============================================================================================
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Post-sync Jobs ━━━"
for script_entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
# ==============================================================================================
# ━━━ Remainder Container Updates ━━━
# ==============================================================================================
# Updates all running containers not already covered by daily or the weekly sync window.
# Runs last — weekly sync-window containers are back up before this pulls their peers.
echo ""
echo "━━━ $ICON_CONTAINERS Remainder Container Updates ━━━"
DOCKER_UPDATE_SCRIPT="$SCRIPTS_ROOT/Docker_Essentials/docker_update.sh"
if [[ ! -f "$DOCKER_UPDATE_SCRIPT" ]]; then
warn "docker_update.sh not found — skipping remainder updates"
JOB_FAIL+=("docker_update.sh --remainder")
else
_remainder_args=("--remainder")
[[ "$DRY_RUN" == true ]] && _remainder_args+=("--dry-run")
if bash "$DOCKER_UPDATE_SCRIPT" "${_remainder_args[@]}"; then
echo "Remainder updates complete ✅"
JOB_PASS+=("docker_update.sh --remainder")
else
warn "Remainder updates completed with errors"
JOB_FAIL+=("docker_update.sh --remainder")
fi
unset _remainder_args
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo "$ICON_GEAR Updates: local=${WEEKLY_SYNC_UPDATES:-false} remote=${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "$ICON_SYNC Sync jobs ($SHARE_COUNT):"
for job in "${PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo " Passed: ${#PASS[@]} Failed: ${#FAIL[@]}"
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo ""
echo "$ICON_GEAR Post-sync jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Weekly maintenance failed on $(hostname) ($MY_ID) — sync: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Weekly Maintenance" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,438 @@
#!/bin/bash
# ==============================================================================================
# ============================= Weekly Sync Maintenance ========================================
# ==============================================================================================
# Weekly maintenance window orchestrator — clean sync, container updates, weekly restarts.
# Schedule: 30 2 * * 0 (Sunday 2:30am — before Sunday 7am coffee report)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. Stop local containers — Emby + auth stack stopped locally
# 2. Stop remote containers — Emby + auth stack stopped remotely via SSH
# 3. Pull updates locally — if WEEKLY_SYNC_UPDATES=true (zero extra downtime)
# 4. Pull updates remotely — if WEEKLY_SYNC_UPDATES_REMOTE=true
# 5. rsync WEEKLY_SYNC_SHARES — full clean mirror, containers stopped both sides
# 6. Start remote containers — correct order, delayed start respected
# 7. Start local containers — rebuild if new image pulled, docker start otherwise
# 8. WEEKLY_MAINTENANCE_SCRIPTS — weekly restarts etc. (docker_weekly_restart.sh)
# 9. docker_update.sh --remainder — update all containers not in daily or weekly sync window
#
# ── WHY WEEKLY NOT NIGHTLY FOR EMBY ──────────────────────────────────────────────────────────
# Emby builds a warm image cache on HOST2 throughout the week.
# Syncing nightly resets cache — cold loads every morning for users.
# Weekly sync: cache stays warm 6 days, resets Sunday night while users sleep.
# emby-fallback dirty sync covers watch states + library every 30min between weekly syncs.
#
# ── CONTAINER UPDATES ─────────────────────────────────────────────────────────────────────────
# Containers already stopped for sync — updates pull at zero extra downtime.
# Both servers start on identical image versions after the window completes.
# Toggle: WEEKLY_SYNC_UPDATES / WEEKLY_SYNC_UPDATES_REMOTE in master.conf
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID — used in banner, summary, and notifications.
# WEEKLY_SYNC_SHARES and WEEKLY_MAINTENANCE_SCRIPTS configured in master.conf.
# Same script runs correctly on both servers.
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — stop/start containers, rsync require root
# acquire_lock — prevents concurrent weekly windows
# check_connectivity — verifies remote before any remote operations
# check_remote_rootfs — aborts if remote rootfs nearly full
# DOCKER_TIMEOUT — all docker calls protected
# SSH_TIMEOUT — all SSH calls protected
# platform_require_cmd — notify validated before use
# Silent on success — runs weekly, only failures warrant notification
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# WEEKLY_SYNC_SHARES — shares synced during window
# WEEKLY_MAINTENANCE_SCRIPTS — scripts run after sync
# WEEKLY_SYNC_UPDATES — toggle local container updates
# WEEKLY_SYNC_UPDATES_REMOTE — toggle remote container updates
# WEEKLY_RSYNC_ENABLED — enable/disable rsync section
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# weekly_sync_maintenance.sh — normal run
# weekly_sync_maintenance.sh --dry-run — preview without stopping containers or syncing
# weekly_sync_maintenance.sh --log — verbose per-share/per-job output
# weekly_sync_maintenance.sh --status — show configuration and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
DOCKER_TIMEOUT=30 # container stop/start needs longer than normal
SSH_TIMEOUT=30 # remote pulls can be slow
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
resolve_remote_ip
WINDOW_START=$(date +%s)
PASS=()
FAIL=()
JOB_PASS=()
JOB_FAIL=()
# Load container lists from profile config
read -r -a MAINTENANCE_CONTAINERS <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped, no sync, no updates"
# ── Helper — run a post-sync maintenance script ────────────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Weekly enabled: ${WEEKLY_RSYNC_ENABLED:-false}"
echo "$ICON_GEAR Local updates: ${WEEKLY_SYNC_UPDATES:-false}"
echo "$ICON_GEAR Remote updates: ${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "━━━ Weekly Sync Shares ━━━"
if [[ ${#WEEKLY_SYNC_SHARES[@]} -eq 0 ]]; then
warn " No WEEKLY_SYNC_SHARES configured"
else
for share in "${WEEKLY_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Weekly Maintenance Scripts ━━━"
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo ""
echo "━━━ Containers (from profile config) ━━━"
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && echo " $ICON_CONTAINERS $c"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight Checks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Weekly Sync Maintenance — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
check_connectivity
check_remote_rootfs
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP $ICON_CONTAINERS Stop Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be stopped"
else
# Load container lists for stop functions
read -r -a CRITICAL_CONTAINER_NAMES <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-}"
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[critical-data]:-}"
CONTAINER_DELAY="${PROFILE_CONTAINER_DELAY[critical-data]:-15}"
stop_local_containers
stop_containers
fi
# ==============================================================================================
# ━━━ Container Updates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Container Updates ━━━"
declare -A _weekly_needs_rebuild=()
if [[ "$WEEKLY_SYNC_UPDATES" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && warn "DRY RUN — would pull: $c"
done
else
log "Pulling local container updates..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$DOCKER_TIMEOUT" docker inspect \
"$c" --format '{{.Config.Image}}' 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found locally, skipping update"
continue
fi
_old_id=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "Pulling $IMAGE for $c..."
if docker pull "$IMAGE" >/dev/null 2>&1; then
_new_id=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ -n "$_old_id" && "$_old_id" != "$_new_id" ]]; then
log "$c — new image (${_old_id:7:12} → ${_new_id:7:12}) — will rebuild after sync"
_weekly_needs_rebuild["$c"]=1
else
log "$c — already current"
fi
else
warn "$c — pull failed, will start on existing image"
fi
done
unset _old_id _new_id
fi
else
echo "WEEKLY_SYNC_UPDATES=false — skipping local updates"
fi
if [[ "$WEEKLY_SYNC_UPDATES_REMOTE" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull updates on $REMOTE_SERVER_NAME"
else
log "Pulling remote container updates on $REMOTE_SERVER_NAME..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker inspect $c --format '{{.Config.Image}}' 2>/dev/null" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found on remote, skipping update"
continue
fi
log "Pulling $IMAGE for $c on $REMOTE_SERVER_NAME..."
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker pull $IMAGE" >/dev/null 2>&1; then
log "$c — remote image updated ✅"
else
warn "$c — remote pull failed, will start on existing image"
fi
done
fi
else
echo "WEEKLY_SYNC_UPDATES_REMOTE=false — skipping remote updates"
fi
# ==============================================================================================
# ━━━ Critical Shares Sync ━━━
# ==============================================================================================
SHARE_COUNT=${#WEEKLY_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Critical Shares Sync — $SHARE_COUNT share(s) ━━━"
SYNC_START=$(date +%s)
JOB_NUM=0
if ! check_rsync_enabled "WEEKLY"; then
warn "Weekly rsync disabled — skipping all $SHARE_COUNT sync job(s)"
warn "Proceeding to container start and maintenance scripts..."
elif [[ "$SHARE_COUNT" -eq 0 ]]; then
warn "No WEEKLY_SYNC_SHARES configured — skipping sync"
warn "Check WEEKLY_SYNC_SHARES in master.conf"
else
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for JOB in "${WEEKLY_SYNC_SHARES[@]}"; do
(( JOB_NUM++ ))
JOB_NAME=$(basename "$JOB")
echo ""
echo "━━━ [$JOB_NUM/$SHARE_COUNT] $JOB_NAME ━━━"
JOB_START=$(date +%s)
bash "$RSYNC_SCRIPT" "$JOB" $RSYNC_DRY
EXIT_CODE=$?
JOB_DUR=$(format_duration $(( $(date +%s) - JOB_START )))
if [[ "$EXIT_CODE" -eq 0 ]]; then
PASS+=("$JOB_NAME")
log "$JOB_NAME — done in $JOB_DUR ✅"
else
FAIL+=("$JOB_NAME")
error "$JOB_NAME — failed after $JOB_DUR (exit $EXIT_CODE)"
fi
echo ""
done
fi
SYNC_END=$(date +%s)
# ==============================================================================================
# ━━━ Start Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Start Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be started"
else
start_containers
# Local start — rebuild containers that received a new image, docker start the rest
if [[ ${#LOCAL_RUNNING_CONTAINERS[@]} -eq 0 ]]; then
log "No local containers to restart."
else
for _c in "${LOCAL_RUNNING_CONTAINERS[@]}"; do
[[ -z "$_c" ]] && continue
_needs_delay=false
for _d in "${DELAYED_CONTAINERS[@]}"; do
[[ "$_c" == "$_d" ]] && _needs_delay=true && break
done
[[ "$_needs_delay" == true ]] && {
info "Waiting ${CONTAINER_DELAY}s before starting $_c..."
sleep "$CONTAINER_DELAY"
}
if [[ -n "${_weekly_needs_rebuild[$_c]:-}" ]]; then
log "Rebuilding $_c on new image..."
if platform_rebuild_container "$_c"; then
log "$_c rebuilt on new image ✅"
else
warn "$_c rebuild failed — falling back to docker start"
docker start "$_c" >/dev/null 2>&1 || error "Failed to start $_c"
fi
else
docker start "$_c" >/dev/null 2>&1 && log "$_c started" || error "Failed to start $_c"
fi
done
unset _c _d _needs_delay
fi
fi
# ==============================================================================================
# ━━━ Post-sync Jobs ━━━
# ==============================================================================================
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Post-sync Jobs ━━━"
for script_entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
# ==============================================================================================
# ━━━ Remainder Container Updates ━━━
# ==============================================================================================
# Updates all running containers not already covered by daily or the weekly sync window.
# Runs last — weekly sync-window containers are back up before this pulls their peers.
echo ""
echo "━━━ $ICON_CONTAINERS Remainder Container Updates ━━━"
DOCKER_UPDATE_SCRIPT="$SCRIPTS_ROOT/Docker_Essentials/docker_update.sh"
if [[ ! -f "$DOCKER_UPDATE_SCRIPT" ]]; then
warn "docker_update.sh not found — skipping remainder updates"
JOB_FAIL+=("docker_update.sh --remainder")
else
_remainder_args=("--remainder")
[[ "$DRY_RUN" == true ]] && _remainder_args+=("--dry-run")
if bash "$DOCKER_UPDATE_SCRIPT" "${_remainder_args[@]}"; then
echo "Remainder updates complete ✅"
JOB_PASS+=("docker_update.sh --remainder")
else
warn "Remainder updates completed with errors"
JOB_FAIL+=("docker_update.sh --remainder")
fi
unset _remainder_args
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo "$ICON_GEAR Updates: local=${WEEKLY_SYNC_UPDATES:-false} remote=${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "$ICON_SYNC Sync jobs ($SHARE_COUNT):"
for job in "${PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo " Passed: ${#PASS[@]} Failed: ${#FAIL[@]}"
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo ""
echo "$ICON_GEAR Post-sync jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Weekly maintenance failed on $(hostname) ($MY_ID) — sync: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Weekly Maintenance" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,412 @@
#!/bin/bash
# ==============================================================================================
# ============================= Weekly Sync Maintenance ========================================
# ==============================================================================================
# Weekly maintenance window orchestrator — clean sync, container updates, weekly restarts.
# Schedule: 30 2 * * 0 (Sunday 2:30am — before Sunday 7am coffee report)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. Stop local containers — Emby + auth stack stopped locally
# 2. Stop remote containers — Emby + auth stack stopped remotely via SSH
# 3. Pull updates locally — if WEEKLY_SYNC_UPDATES=true (zero extra downtime)
# 4. Pull updates remotely — if WEEKLY_SYNC_UPDATES_REMOTE=true
# 5. rsync WEEKLY_SYNC_SHARES — full clean mirror, containers stopped both sides
# 6. Start remote containers — correct order, delayed start respected
# 7. Start local containers — rebuild if new image pulled, docker start otherwise
# 8. WEEKLY_MAINTENANCE_SCRIPTS — weekly restarts etc. (docker_weekly_restart.sh)
#
# ── WHY WEEKLY NOT NIGHTLY FOR EMBY ──────────────────────────────────────────────────────────
# Emby builds a warm image cache on HOST2 throughout the week.
# Syncing nightly resets cache — cold loads every morning for users.
# Weekly sync: cache stays warm 6 days, resets Sunday night while users sleep.
# emby-fallback dirty sync covers watch states + library every 30min between weekly syncs.
#
# ── CONTAINER UPDATES ─────────────────────────────────────────────────────────────────────────
# Containers already stopped for sync — updates pull at zero extra downtime.
# Both servers start on identical image versions after the window completes.
# Toggle: WEEKLY_SYNC_UPDATES / WEEKLY_SYNC_UPDATES_REMOTE in master.conf
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# detect_hosts() sets MY_ID — used in banner, summary, and notifications.
# WEEKLY_SYNC_SHARES and WEEKLY_MAINTENANCE_SCRIPTS configured in master.conf.
# Same script runs correctly on both servers.
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# Root check — stop/start containers, rsync require root
# acquire_lock — prevents concurrent weekly windows
# check_connectivity — verifies remote before any remote operations
# check_remote_rootfs — aborts if remote rootfs nearly full
# DOCKER_TIMEOUT — all docker calls protected
# SSH_TIMEOUT — all SSH calls protected
# platform_require_cmd — notify validated before use
# Silent on success — runs weekly, only failures warrant notification
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# WEEKLY_SYNC_SHARES — shares synced during window
# WEEKLY_MAINTENANCE_SCRIPTS — scripts run after sync
# WEEKLY_SYNC_UPDATES — toggle local container updates
# WEEKLY_SYNC_UPDATES_REMOTE — toggle remote container updates
# WEEKLY_RSYNC_ENABLED — enable/disable rsync section
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# weekly_sync_maintenance.sh — normal run
# weekly_sync_maintenance.sh --dry-run — preview without stopping containers or syncing
# weekly_sync_maintenance.sh --log — verbose per-share/per-job output
# weekly_sync_maintenance.sh --status — show configuration and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
parse_args "$@"
DOCKER_TIMEOUT=30 # container stop/start needs longer than normal
SSH_TIMEOUT=30 # remote pulls can be slow
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
resolve_remote_ip
WINDOW_START=$(date +%s)
PASS=()
FAIL=()
JOB_PASS=()
JOB_FAIL=()
# Load container lists from profile config
read -r -a MAINTENANCE_CONTAINERS <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no containers will be stopped, no sync, no updates"
# ── Helper — run a post-sync maintenance script ────────────────────────────────────────────────
run_job() {
local script_entry="$1"
local extra_dry=""
[[ "$DRY_RUN" == true ]] && extra_dry="--dry-run"
read -r -a script_args <<< "$script_entry"
local script_path="$SCRIPTS_ROOT/${script_args[0]}"
local script_name
script_name=$(basename "${script_args[0]}")
local extra_args=("${script_args[@]:1}")
if [[ ! -f "$script_path" ]]; then
error "$script_name — not found at $script_path"
JOB_FAIL+=("$script_name")
return 1
fi
log "Running: $script_name ${extra_args[*]}"
# shellcheck disable=SC2086
if bash "$script_path" "${extra_args[@]}" $extra_dry; then
log "$script_name — done ✅"
JOB_PASS+=("$script_name ${extra_args[*]}")
else
error "$script_name — failed (exit $?)"
JOB_FAIL+=("$script_name ${extra_args[*]}")
fi
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Weekly enabled: ${WEEKLY_RSYNC_ENABLED:-false}"
echo "$ICON_GEAR Local updates: ${WEEKLY_SYNC_UPDATES:-false}"
echo "$ICON_GEAR Remote updates: ${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "━━━ Weekly Sync Shares ━━━"
if [[ ${#WEEKLY_SYNC_SHARES[@]} -eq 0 ]]; then
warn " No WEEKLY_SYNC_SHARES configured"
else
for share in "${WEEKLY_SYNC_SHARES[@]}"; do
[[ -n "$share" ]] && echo " $ICON_SYNC $(basename "$share") ($share)"
done
fi
echo ""
echo "━━━ Weekly Maintenance Scripts ━━━"
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -n "$entry" ]] && echo " $ICON_GEAR ${entry##*/}"
done
fi
echo ""
echo "━━━ Containers (from profile config) ━━━"
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && echo " $ICON_CONTAINERS $c"
done
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight Checks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Weekly Sync Maintenance — $MY_ID — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
check_connectivity
check_remote_rootfs
# ==============================================================================================
# ━━━ Stop Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP $ICON_CONTAINERS Stop Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be stopped"
else
# Load container lists for stop functions
read -r -a CRITICAL_CONTAINER_NAMES <<< \
"${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-}"
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[critical-data]:-}"
CONTAINER_DELAY="${PROFILE_CONTAINER_DELAY[critical-data]:-15}"
stop_local_containers
stop_containers
fi
# ==============================================================================================
# ━━━ Container Updates ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Container Updates ━━━"
declare -A _weekly_needs_rebuild=()
if [[ "$WEEKLY_SYNC_UPDATES" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -n "$c" ]] && warn "DRY RUN — would pull: $c"
done
else
log "Pulling local container updates..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$DOCKER_TIMEOUT" docker inspect \
"$c" --format '{{.Config.Image}}' 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found locally, skipping update"
continue
fi
_old_id=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
log "Pulling $IMAGE for $c..."
if docker pull "$IMAGE" >/dev/null 2>&1; then
_new_id=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
if [[ -n "$_old_id" && "$_old_id" != "$_new_id" ]]; then
log "$c — new image (${_old_id:7:12} → ${_new_id:7:12}) — will rebuild after sync"
_weekly_needs_rebuild["$c"]=1
else
log "$c — already current"
fi
else
warn "$c — pull failed, will start on existing image"
fi
done
unset _old_id _new_id
fi
else
echo "WEEKLY_SYNC_UPDATES=false — skipping local updates"
fi
if [[ "$WEEKLY_SYNC_UPDATES_REMOTE" == true ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would pull updates on $REMOTE_SERVER_NAME"
else
log "Pulling remote container updates on $REMOTE_SERVER_NAME..."
for c in "${MAINTENANCE_CONTAINERS[@]}"; do
[[ -z "$c" ]] && continue
IMAGE=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker inspect $c --format '{{.Config.Image}}' 2>/dev/null" 2>/dev/null)
if [[ -z "$IMAGE" ]]; then
log "$c — not found on remote, skipping update"
continue
fi
log "Pulling $IMAGE for $c on $REMOTE_SERVER_NAME..."
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"docker pull $IMAGE" >/dev/null 2>&1; then
log "$c — remote image updated ✅"
else
warn "$c — remote pull failed, will start on existing image"
fi
done
fi
else
echo "WEEKLY_SYNC_UPDATES_REMOTE=false — skipping remote updates"
fi
# ==============================================================================================
# ━━━ Critical Shares Sync ━━━
# ==============================================================================================
SHARE_COUNT=${#WEEKLY_SYNC_SHARES[@]}
echo ""
echo "━━━ $ICON_SYNC Critical Shares Sync — $SHARE_COUNT share(s) ━━━"
SYNC_START=$(date +%s)
JOB_NUM=0
if ! check_rsync_enabled "WEEKLY"; then
warn "Weekly rsync disabled — skipping all $SHARE_COUNT sync job(s)"
warn "Proceeding to container start and maintenance scripts..."
elif [[ "$SHARE_COUNT" -eq 0 ]]; then
warn "No WEEKLY_SYNC_SHARES configured — skipping sync"
warn "Check WEEKLY_SYNC_SHARES in master.conf"
else
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for JOB in "${WEEKLY_SYNC_SHARES[@]}"; do
(( JOB_NUM++ ))
JOB_NAME=$(basename "$JOB")
echo ""
echo "━━━ [$JOB_NUM/$SHARE_COUNT] $JOB_NAME ━━━"
JOB_START=$(date +%s)
bash "$RSYNC_SCRIPT" "$JOB" $RSYNC_DRY
EXIT_CODE=$?
JOB_DUR=$(format_duration $(( $(date +%s) - JOB_START )))
if [[ "$EXIT_CODE" -eq 0 ]]; then
PASS+=("$JOB_NAME")
log "$JOB_NAME — done in $JOB_DUR ✅"
else
FAIL+=("$JOB_NAME")
error "$JOB_NAME — failed after $JOB_DUR (exit $EXIT_CODE)"
fi
echo ""
done
fi
SYNC_END=$(date +%s)
# ==============================================================================================
# ━━━ Start Containers ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_START $ICON_CONTAINERS Start Containers ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — containers will not be started"
else
start_containers
# Local start — rebuild containers that received a new image, docker start the rest
if [[ ${#LOCAL_RUNNING_CONTAINERS[@]} -eq 0 ]]; then
log "No local containers to restart."
else
for _c in "${LOCAL_RUNNING_CONTAINERS[@]}"; do
[[ -z "$_c" ]] && continue
_needs_delay=false
for _d in "${DELAYED_CONTAINERS[@]}"; do
[[ "$_c" == "$_d" ]] && _needs_delay=true && break
done
[[ "$_needs_delay" == true ]] && {
info "Waiting ${CONTAINER_DELAY}s before starting $_c..."
sleep "$CONTAINER_DELAY"
}
if [[ -n "${_weekly_needs_rebuild[$_c]:-}" ]]; then
log "Rebuilding $_c on new image..."
if platform_rebuild_container "$_c"; then
log "$_c rebuilt on new image ✅"
else
warn "$_c rebuild failed — falling back to docker start"
docker start "$_c" >/dev/null 2>&1 || error "Failed to start $_c"
fi
else
docker start "$_c" >/dev/null 2>&1 && log "$_c started" || error "Failed to start $_c"
fi
done
unset _c _d _needs_delay
fi
fi
# ==============================================================================================
# ━━━ Post-sync Jobs ━━━
# ==============================================================================================
if [[ ${#WEEKLY_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_GEAR Post-sync Jobs ━━━"
for script_entry in "${WEEKLY_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" ]] && continue
echo ""
run_job "$script_entry"
done
fi
WINDOW_END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY WEEKLY SYNC MAINTENANCE SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Window: $(date -d @"$WINDOW_START" '+%Y-%m-%d %H:%M:%S') → $(date -d @"$WINDOW_END" '+%H:%M:%S')"
echo "$ICON_TIME Duration: $(format_duration $(( WINDOW_END - WINDOW_START )))"
echo "$ICON_GEAR Updates: local=${WEEKLY_SYNC_UPDATES:-false} remote=${WEEKLY_SYNC_UPDATES_REMOTE:-false}"
echo ""
echo "$ICON_SYNC Sync jobs ($SHARE_COUNT):"
for job in "${PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${FAIL[@]}"; do echo " $ICON_ERROR $job"; done
echo " Passed: ${#PASS[@]} Failed: ${#FAIL[@]}"
if [[ ${#JOB_PASS[@]} -gt 0 || ${#JOB_FAIL[@]} -gt 0 ]]; then
echo ""
echo "$ICON_GEAR Post-sync jobs:"
for job in "${JOB_PASS[@]}"; do echo " $ICON_DONE $job"; done
for job in "${JOB_FAIL[@]}"; do echo " $ICON_ERROR $job"; done
fi
TOTAL_FAIL=$(( ${#FAIL[@]} + ${#JOB_FAIL[@]} ))
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ "$TOTAL_FAIL" -eq 0 ]]; then
echo "$ICON_DONE Status: all complete ✅ — ${#PASS[@]} share(s) synced, ${#JOB_PASS[@]} job(s) run"
else
warn "Status: $TOTAL_FAIL failure(s)"
notify "Weekly maintenance failed on $(hostname) ($MY_ID) — sync: ${#FAIL[@]}/$SHARE_COUNT failed, jobs: ${#JOB_FAIL[@]} failed" \
"Weekly Maintenance" "warning"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ "$TOTAL_FAIL" -gt 0 ]] && exit 1
exit 0
@@ -0,0 +1,164 @@
#!/bin/bash
# ==============================================================================================
# ============================= Docker Prune Images ============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Removes orphaned Docker images. Two modes:
#
# Default — dangling only (safe, fast):
# Removes untagged images (no name, no container reference). These accumulate
# after container updates pull a new image, leaving the old one untagged.
# Running containers are never affected.
#
# --all — full orphan cleanup:
# Step 1: removes stopped containers (exited/created state).
# Step 2: removes all images not used by any running container.
# Use this to clear tagged images left behind by removed or stopped apps.
# CAUTION: also removes intentionally stopped containers — only run when you
# know all stopped containers are safe to delete.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# docker_prune_images.sh
# Remove dangling (untagged) images only.
#
# docker_prune_images.sh --all
# Remove stopped containers, then remove all unused images.
#
# docker_prune_images.sh --dry-run
# Show what would be removed without making changes.
#
# docker_prune_images.sh --status
# Show dangling images and stopped containers. No changes.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Handle --all before parse_args ────────────────────────────────────────────
ALL_MODE=false
FILTERED_ARGS=()
for _arg in "$@"; do
[[ "$_arg" == "--all" ]] && ALL_MODE=true || FILTERED_ARGS+=("$_arg")
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY DOCKER PRUNE IMAGES STATUS ━━━━━"
DANGLING=$(docker images -f "dangling=true" --format "{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}" 2>/dev/null)
STOPPED=$(docker ps -a --filter "status=exited" --filter "status=created" \
--format "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null)
echo ""
echo "$ICON_CONTAINERS Dangling images (no tag, no container):"
if [[ -z "$DANGLING" ]]; then
echo " none"
else
echo "$DANGLING" | while IFS=$'\t' read -r id repo size age; do
echo " $id $repo $size $age"
done
fi
echo ""
echo "$ICON_CONTAINERS Stopped containers (--all would remove these first):"
if [[ -z "$STOPPED" ]]; then
echo " none"
else
echo "$STOPPED" | while IFS=$'\t' read -r id name image status; do
echo " $name ($image) $status"
done
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Run ━━━
# ==============================================================================================
MODE_LABEL=$([[ "$ALL_MODE" == true ]] && echo "full orphan cleanup" || echo "dangling only")
log "$ICON_GEAR Config: mode=${MODE_LABEL} dry-run=${DRY_RUN}"
echo "━━━ $ICON_CONTAINERS Docker Prune Images ($MODE_LABEL) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
TOTAL_RECLAIMED=0
# ── Step 1 (--all only): remove stopped containers ────────────────────────────
if [[ "$ALL_MODE" == true ]]; then
STOPPED_IDS=$(docker ps -a --filter "status=exited" --filter "status=created" -q 2>/dev/null)
STOPPED_COUNT=$(echo "$STOPPED_IDS" | grep -c . || echo 0)
if [[ "$STOPPED_COUNT" -eq 0 ]]; then
log "No stopped containers"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove $STOPPED_COUNT stopped container(s):"
docker ps -a --filter "status=exited" --filter "status=created" \
--format " {{.Names}} {{.Image}} {{.Status}}" 2>/dev/null
else
log "Removing $STOPPED_COUNT stopped container(s)..."
docker container prune -f 2>&1 | grep -v "^Total\|^$" || true
success "Removed $STOPPED_COUNT stopped container(s)"
fi
echo ""
fi
# ── Step 2: prune images ───────────────────────────────────────────────────────
PRUNE_FLAGS=$([[ "$ALL_MODE" == true ]] && echo "-a" || echo "")
PRUNE_FILTER=$([[ "$ALL_MODE" == true ]] && echo "" || echo '-f "dangling=true"')
# Preview count
if [[ "$ALL_MODE" == true ]]; then
# Images not used by any running container
RUNNING_IMAGES=$(docker ps --format "{{.Image}}" 2>/dev/null)
UNUSED_COUNT=$(docker images --format "{{.Repository}}:{{.Tag}}" 2>/dev/null \
| grep -vxF "$RUNNING_IMAGES" | grep -c . || echo 0)
TARGET_LABEL="$UNUSED_COUNT unused image(s)"
else
DANGLING_IDS=$(docker images -f "dangling=true" -q 2>/dev/null)
UNUSED_COUNT=$(echo "$DANGLING_IDS" | grep -c . || echo 0)
TARGET_LABEL="$UNUSED_COUNT dangling image(s)"
fi
if [[ "$UNUSED_COUNT" -eq 0 ]]; then
success "No images to remove"
exit 0
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove $TARGET_LABEL:"
if [[ "$ALL_MODE" == true ]]; then
docker images --format " {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}" 2>/dev/null \
| grep -vF "$(docker ps --format '{{.Image}}' 2>/dev/null)" || true
else
docker images -f "dangling=true" \
--format " {{.ID}} {{.Size}} created {{.CreatedSince}}" 2>/dev/null
fi
exit 0
fi
log "Pruning $TARGET_LABEL..."
OUTPUT=$(docker image prune $PRUNE_FLAGS -f 2>&1)
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$OUTPUT" | sed 's/^/ /'
RECLAIMED=$(echo "$OUTPUT" | grep -E "^Total reclaimed" || echo "Total reclaimed space: unknown")
success "Done — $RECLAIMED"
@@ -0,0 +1,808 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$numDisabled = (int)($var['mdNumDisabled'] ?? 0);
$numMissing = (int)($var['mdNumMissing'] ?? 0);
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
// Emulated (DISK_DSBL) disks are protected by parity and don't make parity invalid.
// True invalidity: sync errors on the last check, or unprotectable missing slots.
$isValid = $errors === 0 && $numMissing === 0;
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'num_disabled' => $numDisabled,
'num_missing' => $numMissing,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active session dirs in both known locations
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,823 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/unraid_api.php';
// Common helpers shared across all Varaverk pages.
function vv_system_info(): array {
// ── Shared local reads (always needed regardless of API) ──────────────────
$ident = @parse_ini_file('/boot/config/ident.cfg') ?: [];
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$version = trim(@file_get_contents('/etc/unraid-version') ?: '');
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api) {
$os = $api['info']['os'] ?? [];
$cpu = $api['info']['cpu'] ?? [];
// uptime is a String in this schema — try numeric (seconds) first, else display as-is
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$uptime = vv_format_uptime($uptimeSec);
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$load = sys_getloadavg();
return [
'name' => $os['hostname'] ?? ($ident['NAME'] ?? gethostname()),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $cpu['brand'] ?? ($ident['SYS_MODEL'] ?? ''),
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'cpu_cores' => (int)($cpu['cores'] ?? 0),
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => strtoupper($api['array']['state'] ?? $var['mdState'] ?? 'UNKNOWN'),
'version' => trim($os['release'] ?? '') ?: $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('system_info');
$cpuModel = '';
foreach (@file('/proc/cpuinfo') ?: [] as $line) {
if (preg_match('/^model name\s*:\s*(.+)/', $line, $m)) { $cpuModel = trim($m[1]); break; }
}
$uptimeSec = (int)explode(' ', @file_get_contents('/proc/uptime') ?: '0')[0];
$uptime = vv_format_uptime($uptimeSec);
$load = sys_getloadavg();
return [
'name' => $ident['NAME'] ?? gethostname(),
'comment' => $ident['COMMENT'] ?? '',
'timezone' => $ident['timeZone'] ?? 'UTC',
'cpu_model' => $ident['SYS_MODEL'] ?? $cpuModel,
'cpu_threads' => 0,
'cpu_cores' => 0,
'reg_type' => 'Unraid OS ' . ($var['regTy'] ?? ''),
'reg_to' => $var['regTo'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'array_state' => $var['mdState'] ?? 'UNKNOWN',
'version' => $version,
'load_avg' => $load ? [round($load[0], 2), round($load[1], 2), round($load[2], 2)] : null,
];
}
function vv_docker_containers(): array {
$out = shell_exec('docker ps --format \'{"name":"{{.Names}}","status":"{{.Status}}","image":"{{.Image}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_docker_stopped(): array {
$out = shell_exec('docker ps -a --filter "status=exited" --filter "status=created" --format \'{"name":"{{.Names}}","status":"{{.Status}}"}\' 2>/dev/null');
$containers = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$c = json_decode($line, true);
if ($c) $containers[] = $c;
}
return $containers;
}
function vv_gpu_stats(): array {
$out = shell_exec('nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw,utilization.encoder,utilization.decoder --format=csv,noheader,nounits 2>/dev/null');
if (!$out) return ['available' => false];
$parts = array_map('trim', explode(',', $out));
$power = is_numeric($parts[5] ?? '') ? round((float)$parts[5], 1) : null;
return [
'available' => true,
'name' => $parts[0] ?? '',
'memory_used' => (int)($parts[1] ?? 0),
'memory_total' => (int)($parts[2] ?? 0),
'utilization' => (int)($parts[3] ?? 0),
'temperature' => (int)($parts[4] ?? 0),
'power_w' => $power,
'enc_pct' => (int)($parts[6] ?? 0),
'dec_pct' => (int)($parts[7] ?? 0),
];
}
function vv_gpu_processes(): array {
$out = shell_exec('nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader,nounits 2>/dev/null');
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
$parts = array_map('trim', explode(',', $line));
$procs[] = [
'pid' => $parts[0] ?? '',
'memory_mb' => $parts[1] ?? '',
'name' => $parts[2] ?? '',
];
}
return $procs;
}
function vv_system_resources(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(MemTotal|MemAvailable):\s+(\d+)/', $line, $m))
$mem[$m[1]] = (int)$m[2];
}
return [
'ram_total_mb' => (int)(($mem['MemTotal'] ?? 0) / 1024),
'ram_free_mb' => (int)(($mem['MemAvailable'] ?? 0) / 1024),
'cache' => vv_df('/mnt/cache'),
];
}
function vv_cpu_per_core(): array {
// Parse /proc/stat — [user, nice, system, idle, iowait, irq, softirq]
$raw = [];
foreach (file('/proc/stat') ?: [] as $line) {
if (!preg_match('/^(cpu\d*)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $line, $m)) continue;
$raw[$m[1]] = [(int)$m[2],(int)$m[3],(int)$m[4],(int)$m[5],(int)$m[6],(int)$m[7],(int)$m[8]];
}
$stateFile = VV_CACHE_DIR . '/vv_cpu_stat.json';
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
// Atomic write — concurrent fast/slow polls read a consistent snapshot
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($raw));
rename($tmp, $stateFile);
$usage = function(array $c, ?array $p): int {
if (!$p) return 0;
$dt = array_sum($c) - array_sum($p);
$di = ($c[3] + $c[4]) - ($p[3] + $p[4]);
return $dt > 0 ? max(0, min(100, (int)round((1 - $di / $dt) * 100))) : 0;
};
$overall = $usage($raw['cpu'] ?? [], $prev['cpu'] ?? null);
$cores = [];
foreach ($raw as $cpu => $c) {
if ($cpu === 'cpu') continue;
$num = (int)substr($cpu, 3);
$freqKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/scaling_cur_freq");
$maxKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_max_freq");
$minKhz = (int)@file_get_contents("/sys/devices/system/cpu/$cpu/cpufreq/cpuinfo_min_freq");
$cores[] = [
'core' => $num,
'usage_pct' => $usage($c, $prev[$cpu] ?? null),
'freq_mhz' => $freqKhz > 0 ? (int)round($freqKhz / 1000) : 0,
'max_mhz' => $maxKhz > 0 ? (int)round($maxKhz / 1000) : 0,
'min_mhz' => $minKhz > 0 ? (int)round($minKhz / 1000) : 0,
];
}
usort($cores, fn($a, $b) => $a['core'] - $b['core']);
return ['overall' => $overall, 'cores' => $cores];
}
function vv_memory_breakdown(): array {
$mem = [];
foreach (file('/proc/meminfo') ?: [] as $line) {
if (preg_match('/^(\w+):\s+(\d+)/', $line, $m)) $mem[$m[1]] = (int)$m[2];
}
$totalKb = $mem['MemTotal'] ?? 0;
// ZFS ARC
$arcKb = 0;
foreach (@file('/proc/spl/kstat/zfs/arcstats') ?: [] as $line) {
if (preg_match('/^size\s+\d+\s+(\d+)/', $line, $m)) { $arcKb = (int)($m[1] / 1024); break; }
}
// Docker — sum docker stats used memory per container (matches Unraid dashboard)
$dockerKb = 0;
$dsOut = shell_exec("docker stats --no-stream --format '{{.MemUsage}}' 2>/dev/null") ?: '';
foreach (explode("\n", trim($dsOut)) as $line) {
if (!preg_match('/^([0-9.]+)(GiB|MiB|KiB|B)\s*\//', trim($line), $m)) continue;
$val = (float)$m[1];
$dockerKb += match($m[2]) {
'GiB' => (int)($val * 1048576),
'MiB' => (int)($val * 1024),
'KiB' => (int)$val,
default => (int)($val / 1024),
};
}
// VM (QEMU/KVM RSS)
$vmKb = 0;
foreach (preg_split('/\s+/', trim(shell_exec('ps -C qemu-system-x86_64 -o rss= 2>/dev/null') ?: '')) as $rss) {
if (is_numeric($rss) && $rss > 0) $vmKb += (int)$rss;
}
$freeKb = max(0, $mem['MemAvailable'] ?? 0);
$systemKb = max(0, $totalKb - $freeKb - $arcKb - $dockerKb - $vmKb);
// Top processes by RSS — group same-named procs, take top 5
$grouped = [];
$psOut = shell_exec("ps -eo comm,rss --sort=-rss 2>/dev/null | tail -n +2 | head -40") ?: '';
foreach (explode("\n", trim($psOut)) as $line) {
$parts = preg_split('/\s+/', trim($line), 2);
if (count($parts) === 2 && is_numeric($parts[1]) && (int)$parts[1] > 0)
$grouped[$parts[0]] = ($grouped[$parts[0]] ?? 0) + (int)$parts[1];
}
arsort($grouped);
$topProcs = [];
foreach (array_slice($grouped, 0, 3, true) as $name => $kb)
$topProcs[] = ['name' => $name, 'kb' => $kb];
// Swap — from API metrics when available, else /proc/meminfo
$swapTotalKb = 0; $swapUsedKb = 0;
$apiMem = vv_api_data()['metrics']['memory'] ?? [];
if (!empty($apiMem['swapTotal'])) {
$swapTotalKb = (int)(((float)$apiMem['swapTotal']) / 1024);
$swapUsedKb = (int)(((float)$apiMem['swapUsed']) / 1024);
} else {
$swapTotalKb = $mem['SwapTotal'] ?? 0;
$swapUsedKb = ($mem['SwapTotal'] ?? 0) - ($mem['SwapFree'] ?? 0);
}
return [
'total_kb' => $totalKb,
'system_kb' => $systemKb,
'vm_kb' => $vmKb,
'zfs_kb' => $arcKb,
'docker_kb' => $dockerKb,
'free_kb' => $freeKb,
'swap_total_kb' => $swapTotalKb,
'swap_used_kb' => $swapUsedKb,
'top_procs' => $topProcs,
];
}
function vv_df(string $path): array {
$out = shell_exec("df -BM --output=size,used,avail '$path' 2>/dev/null | tail -1");
if (!$out) return ['available' => false, 'path' => $path];
$parts = preg_split('/\s+/', trim($out));
return [
'available' => true,
'path' => $path,
'size_mb' => (int)$parts[0],
'used_mb' => (int)$parts[1],
'free_mb' => (int)$parts[2],
];
}
function vv_network_stats(): array {
$iface = trim(shell_exec("ip route show default 2>/dev/null | awk 'NR==1{print \$5}'") ?: '');
if (!$iface) {
$best = ''; $bestBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*(\w+):\s+(\d+)/', $line, $m) || $m[1] === 'lo') continue;
if ((int)$m[2] > $bestBytes) { $bestBytes = (int)$m[2]; $best = $m[1]; }
}
$iface = $best;
}
if (!$iface) return ['available' => false];
$rxBytes = $txBytes = 0;
foreach (file('/proc/net/dev') ?: [] as $line) {
if (!preg_match('/^\s*' . preg_quote($iface, '/') . ':\s+(.+)$/', $line, $m)) continue;
$parts = preg_split('/\s+/', trim($m[1]));
$rxBytes = (int)($parts[0] ?? 0);
$txBytes = (int)($parts[8] ?? 0);
break;
}
$stateFile = VV_CACHE_DIR . '/vv_net_stat.json';
$now = ['rx' => $rxBytes, 'tx' => $txBytes, 'ts' => microtime(true)];
$prev = file_exists($stateFile) ? (json_decode(file_get_contents($stateFile), true) ?: []) : [];
$tmp = $stateFile . '.tmp';
file_put_contents($tmp, json_encode($now));
rename($tmp, $stateFile);
$rxRate = $txRate = 0;
if (!empty($prev['ts']) && ($dt = $now['ts'] - $prev['ts']) > 0.1) {
$rxRate = max(0, (int)(($rxBytes - ($prev['rx'] ?? $rxBytes)) / $dt));
$txRate = max(0, (int)(($txBytes - ($prev['tx'] ?? $txBytes)) / $dt));
}
$speedMbps = (int)@file_get_contents("/sys/class/net/$iface/speed");
// Local IP — use primary iface
$localIp = trim(shell_exec(
"ip -4 addr show " . escapeshellarg($iface) . " 2>/dev/null | awk '/inet /{print \$2}' | cut -d/ -f1 | head -1"
) ?: '');
// External IP — curl ifconfig.me, cached 5 min so we don't hammer it
$extIp = '';
$extData = vv_cache_read('ext_ip', 300);
if ($extData) {
$extIp = $extData['ip'] ?? '';
} else {
$fetched = trim(shell_exec('curl -sf --max-time 4 https://ifconfig.me 2>/dev/null') ?: '');
if (preg_match('/^\d+\.\d+\.\d+\.\d+$/', $fetched)) {
$extIp = $fetched;
vv_cache_write('ext_ip', ['ip' => $extIp]);
}
}
// Tailscale IP — use `tailscale ip` CLI (interface name varies: tailscale0, tailscale1, etc.)
$tsIp = trim(shell_exec('tailscale ip -4 2>/dev/null | head -1') ?: '');
return [
'available' => true,
'iface' => $iface,
'speed_mbps' => $speedMbps > 0 ? $speedMbps : null,
'rx_bps' => $rxRate,
'tx_bps' => $txRate,
'local_ip' => $localIp,
'ext_ip' => $extIp,
'ts_ip' => $tsIp,
];
}
function vv_disk_entry(array $d, string $key, string $role = 'data'): ?array {
$name = $d['name'] ?? $key;
$isParity = $role === 'parity';
$mounted = ($d['fsStatus'] ?? '') === 'Mounted';
// Parity has no filesystem — use raw size only
$size_kb = (int)($isParity ? ($d['size'] ?? 0) : ($mounted ? ($d['fsSize'] ?? 0) : ($d['size'] ?? 0)));
$used_kb = (int)($isParity ? 0 : ($mounted ? ($d['fsUsed'] ?? 0) : 0));
if ($size_kb <= 0) return null;
$tempRaw = trim($d['temp'] ?? '');
return [
'name' => $name,
'device' => $d['device'] ?? $key,
'role' => $role,
'size_gb' => round($size_kb / 1048576, 1),
'used_gb' => round($used_kb / 1048576, 1),
'pct' => (!$isParity && $size_kb > 0) ? round($used_kb / $size_kb * 100, 1) : null,
'temp' => is_numeric($tempRaw) ? (int)$tempRaw : null,
'transport' => $d['transport'] ?? 'ata',
'mounted' => $mounted,
'status' => $d['status'] ?? '',
];
}
function vv_ups_stats(): array {
$raw = shell_exec('apcaccess 2>/dev/null') ?: '';
if (!$raw) return ['available' => false];
$fields = [];
foreach (explode("\n", $raw) as $line) {
if (preg_match('/^(\w+)\s*:\s*(.+)$/', trim($line), $m)) {
$fields[trim($m[1])] = trim($m[2]);
}
}
if (empty($fields)) return ['available' => false];
$parse_num = fn(string $k) => isset($fields[$k]) ? (float)$fields[$k] : null;
$loadPct = $parse_num('LOADPCT');
$nomPower = $parse_num('NOMPOWER');
$watts = ($loadPct !== null && $nomPower !== null) ? round($loadPct / 100 * $nomPower) : null;
return [
'available' => true,
'model' => $fields['MODEL'] ?? '',
'status' => trim(explode(' ', $fields['STATUS'] ?? 'UNKNOWN')[0]),
'line_v' => $parse_num('LINEV'),
'output_v' => $parse_num('OUTPUTV'),
'load_pct' => $loadPct,
'nom_power' => $nomPower,
'watts' => $watts,
'bcharge' => $parse_num('BCHARGE'),
'timeleft' => $parse_num('TIMELEFT'),
'num_xfers' => (int)($fields['NUMXFERS'] ?? 0),
'on_batt_s' => $parse_num('CUMONBATT'),
'selftest' => $fields['SELFTEST'] ?? '',
];
}
function vv_parity_status(): array {
$var = [];
foreach (@file('/var/local/emhttp/var.ini') ?: [] as $line) {
if (preg_match('/^(\w+)="([^"]*)"/', $line, $m)) $var[$m[1]] = $m[2];
}
$numDisabled = (int)($var['mdNumDisabled'] ?? 0);
$numMissing = (int)($var['mdNumMissing'] ?? 0);
$exitCode = (int)($var['sbSyncExit'] ?? 0);
$errors = (int)($var['sbSyncErrs'] ?? 0);
// Emulated (DISK_DSBL) disks are protected by parity and don't make parity invalid.
// True invalidity: sync errors on the last check, or unprotectable missing slots.
$isValid = $errors === 0 && $numMissing === 0;
$inProgress = ($var['mdResync'] ?? '0') !== '0';
$resyncPos = (int)($var['mdResyncPos'] ?? 0);
$resyncSize = (int)($var['mdResyncSize'] ?? 1);
$resyncPct = $resyncSize > 0 ? round($resyncPos / $resyncSize * 100, 1) : 0;
// Last check from log
$lastDate = null; $lastDuration = 0; $lastSpeed = 0; $lastErrors = 0; $lastExit = 0;
$logFile = '/boot/config/parity-checks.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
if ($lines) {
$p = explode('|', trim(end($lines)));
$lastDate = trim($p[0] ?? '');
$lastDuration = (int)($p[1] ?? 0);
$lastSpeed = (int)($p[2] ?? 0);
$lastExit = (int)($p[3] ?? 0);
$lastErrors = (int)($p[4] ?? 0);
}
}
// Parse last date string to timestamp
$lastTs = $lastDate ? strtotime($lastDate) : null;
// Next scheduled check from cron
$nextTs = null;
$cronFile = '/boot/config/plugins/dynamix/parity-check.cron';
foreach (@file($cronFile) ?: [] as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (!str_contains($line, 'mdcmd')) continue;
$p = preg_split('/\s+/', $line);
// cron: min hour dom month dow command...
if (count($p) >= 5 && is_numeric($p[0]) && is_numeric($p[1]) && is_numeric($p[2])) {
$next = new DateTime('now');
$next->setTime((int)$p[1], (int)$p[0], 0);
$next->setDate((int)$next->format('Y'), (int)$next->format('n'), (int)$p[2]);
if ($next->getTimestamp() <= time()) $next->modify('+1 month');
$nextTs = $next->getTimestamp();
}
break;
}
$exitMap = ['0' => 'Completed', '-4' => 'Aborted', '-5' => 'Cancelled'];
return [
'valid' => $isValid,
'num_disabled' => $numDisabled,
'num_missing' => $numMissing,
'in_progress' => $inProgress,
'resync_pct' => $resyncPct,
'exit_code' => $exitCode,
'exit_label' => $exitMap[(string)$lastExit] ?? 'Unknown',
'errors' => $lastErrors,
'last_date' => $lastDate,
'last_ts' => $lastTs,
'last_duration' => $lastDuration,
'last_speed_mb' => $lastSpeed > 0 ? round($lastSpeed / 1048576, 1) : null,
'next_ts' => $nextTs,
];
}
function vv_storage_pools(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && isset($api['array']['caches'])) {
$out = [];
foreach ($api['array']['caches'] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $out[] = $entry;
}
if (!empty($out)) {
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('storage_pools');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$out = [];
foreach ($ini as $key => $d) {
if (($d['type'] ?? '') !== 'Cache') continue;
if (($d['fsStatus'] ?? '') !== 'Mounted') continue;
$entry = vv_disk_entry($d, $key);
if ($entry) $out[] = $entry;
}
usort($out, fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
function vv_array_disks(): array {
// ── API path ──────────────────────────────────────────────────────────────
$api = vv_api_data();
if ($api && (isset($api['array']['parities']) || isset($api['array']['disks']))) {
$parity = [];
$data = [];
foreach ($api['array']['parities'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'parity');
if ($entry) $parity[] = $entry;
}
foreach ($api['array']['disks'] ?? [] as $d) {
$entry = vv_api_disk_entry($d, 'data');
if ($entry) $data[] = $entry;
}
if (!empty($parity) || !empty($data)) {
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
}
// ── Local fallback ────────────────────────────────────────────────────────
vv_api_record_fallback('array_disks');
$ini = @parse_ini_file('/var/local/emhttp/disks.ini', true) ?: [];
$parity = [];
$data = [];
foreach ($ini as $key => $d) {
$type = $d['type'] ?? '';
if ($type === 'Parity') {
$entry = vv_disk_entry($d, $key, 'parity');
if ($entry) $parity[] = $entry;
} elseif ($type === 'Data') {
$entry = vv_disk_entry($d, $key, 'data');
if ($entry) $data[] = $entry;
}
}
usort($parity, fn($a, $b) => strnatcmp($a['name'], $b['name']));
usort($data, fn($a, $b) => strnatcmp($a['name'], $b['name']));
return array_merge($parity, $data);
}
function vv_disk_io_rates(): array {
$snapFile = VV_CACHE_DIR . '/vv_diskio_snap.json';
$now = microtime(true);
// Read current whole-disk stats from /proc/diskstats
$current = [];
foreach (@file('/proc/diskstats', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
$p = preg_split('/\s+/', trim($line));
if (count($p) < 14) continue;
$dev = $p[2];
// Keep only whole disks: sda/sdb, nvme0n1, md*, not sda1/nvme0n1p1
if (!preg_match('/^(sd[a-z]+|nvme\d+n\d+|md\d+)$/', $dev)) continue;
$current[$dev] = [(int)$p[5], (int)$p[9]]; // [sectors_read, sectors_written]
}
// Load previous snapshot
$snap = @json_decode(@file_get_contents($snapFile) ?: '', true) ?: [];
$prevTime = (float)($snap['t'] ?? $now);
$prev = $snap['d'] ?? [];
// Save current snapshot
@file_put_contents($snapFile, json_encode(['t' => $now, 'd' => $current], JSON_UNESCAPED_UNICODE));
$dt = max(0.5, $now - $prevTime);
$out = [];
foreach ($current as $dev => [$rs, $ws]) {
$entry = [
'tr' => round($rs * 512 / 1073741824, 2), // cumulative GB read
'tw' => round($ws * 512 / 1073741824, 2), // cumulative GB written
];
if (isset($prev[$dev])) {
[$prs, $pws] = $prev[$dev];
$r = max(0.0, ($rs - $prs) * 512 / $dt / 1048576);
$w = max(0.0, ($ws - $pws) * 512 / $dt / 1048576);
if ($r > 0.01) $entry['r'] = round($r, 1);
if ($w > 0.01) $entry['w'] = round($w, 1);
}
$out[$dev] = $entry;
}
return $out;
}
function vv_disk_thresholds(): array {
$cfg = @file_get_contents('/boot/config/plugins/dynamix/dynamix.cfg') ?: '';
$get = function(string $key) use ($cfg): ?int {
return preg_match('/^\s*' . preg_quote($key, '/') . '\s*=\s*"?(\d+)"?/m', $cfg, $m)
? (int)$m[1] : null;
};
return [
'util_warn' => $get('warning') ?? 70,
'util_crit' => $get('critical') ?? 90,
'hdd_warn' => $get('hot') ?? 45,
'hdd_crit' => $get('max') ?? 55,
'ssd_warn' => $get('hotssd') ?? 60,
'ssd_crit' => $get('maxssd') ?? 70,
];
}
// Fetch a lightweight snapshot from each remote host that has an API key configured.
// Results are cached in /tmp for 30 seconds so rapid monitor polls don't hammer remote hosts.
function vv_remote_hosts_stats(): array {
// Read ALL conf files — remote host keys live in their own host*.conf, not the current host's.
$vars = vv_conf_vars();
foreach (glob(CONF_DIR . '/host*.conf') ?: [] as $f) {
$raw = file_get_contents($f) ?: '';
preg_match_all('/^\s*([A-Z0-9_]+)\s*=\s*["\']?([^"\'#\n]*?)["\']?\s*(?:#.*)?$/m', $raw, $m);
foreach ($m[1] as $i => $key) {
if (!isset($vars[$key])) $vars[$key] = trim($m[2][$i]);
}
}
$myHost = vv_detect_host();
$hostIds = array_filter(array_keys($vars), fn($k) => preg_match('/^HOST\d+$/', $k) && ($vars[$k] ?? '') !== '');
sort($hostIds);
$results = [];
foreach ($hostIds as $id) {
if (strtolower($id) === strtolower($myHost)) continue;
// Background cache written by remote_arr_cache_writer.sh every 2h — use it if present.
$bgCache = VV_CACHE_DIR . '/monitor_remote_' . strtolower($id) . '.json';
if (file_exists($bgCache)) {
$cached = json_decode(file_get_contents($bgCache), true);
if ($cached) {
$cached['cache_age'] = time() - (int)filemtime($bgCache);
$results[$id] = $cached;
continue;
}
}
// No background cache yet — fall back to live call (uses 30s inline cache).
$key = $vars[strtoupper($id) . '_UNRAID_API_KEY'] ?? '';
if (!$key) {
$results[$id] = ['available' => false, 'no_api_key' => true,
'host_id' => $id, 'hostname' => $vars[$id]];
continue;
}
$cacheFile = VV_CACHE_DIR . "/vv_remote_{$id}.json";
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < 30) {
$cached = json_decode(file_get_contents($cacheFile), true);
if ($cached) { $results[$id] = $cached; continue; }
}
$gql = '{
info { os { hostname uptime release } cpu { brand threads cores } }
metrics { cpu { percentTotal } memory { percentTotal total used available } }
array {
state
disks { fsSize fsUsed temp }
caches { fsSize fsUsed temp }
parities { temp }
}
vms { domains { name } }
}';
$data = vv_unraid_api_query(strtolower($id), $gql, 4, $key);
if (!$data) {
$entry = ['available' => false, 'host_id' => $id, 'hostname' => $vars[$id]];
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
continue;
}
$os = $data['info']['os'] ?? [];
$cpu = $data['info']['cpu'] ?? [];
$mMem = $data['metrics']['memory'] ?? [];
$memPct = round((float)($mMem['percentTotal'] ?? 0));
if ($memPct === 0) {
$totalBytes = (float)($mMem['total'] ?? 0);
$availBytes = (float)($mMem['available'] ?? 0);
$memPct = $totalBytes > 0 ? (int)round(($totalBytes - $availBytes) / $totalBytes * 100) : 0;
}
$memTotalGb = isset($mMem['total']) ? _vv_api_bytes_to_gb((float)$mMem['total']) : 0;
$uptimeRaw = $os['uptime'] ?? '';
if (is_numeric($uptimeRaw)) {
$uptimeSec = (int)$uptimeRaw;
$days = intdiv($uptimeSec, 86400);
$hours = intdiv($uptimeSec % 86400, 3600);
$mins = intdiv($uptimeSec % 3600, 60);
$uptime = ($days ? "{$days}d " : '') . ($hours ? "{$hours}h " : '') . "{$mins}m";
} else {
$uptimeSec = 0;
$uptime = $uptimeRaw ?: '—';
}
$nodeMetrics = vv_api_node_metrics($data);
$entry = array_merge([
'available' => true,
'host_id' => $id,
'hostname' => $os['hostname'] ?? $vars[$id],
'version' => $os['release'] ?? '',
'uptime' => $uptime,
'uptime_sec' => $uptimeSec,
'cpu_load' => $nodeMetrics['cpu_pct'] ?? 0,
'cpu_threads' => (int)($cpu['threads'] ?? 0),
'mem_total_gb' => $memTotalGb,
'mem_used_pct' => $memPct,
'array_state' => $data['array']['state'] ?? 'UNKNOWN',
], $nodeMetrics);
file_put_contents($cacheFile, json_encode($entry));
$results[$id] = $entry;
}
return $results;
}
function vv_log_tail(string $path, int $lines): string {
$fp = @fopen($path, 'r');
if (!$fp) return '';
fseek($fp, 0, SEEK_END);
$size = ftell($fp);
if ($size <= 0) { fclose($fp); return ''; }
$chunk = min($size, 4096);
fseek($fp, -$chunk, SEEK_END);
$data = fread($fp, $chunk);
fclose($fp);
$all = explode("\n", $data ?: '');
return implode("\n", array_slice($all, -$lines));
}
function vv_parse_bash_array(string $raw, string $varName): array {
if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\(([^)]*)\)/ms', $raw, $m)) return [];
$items = [];
foreach (explode("\n", $m[1]) as $line) {
$line = trim(preg_replace('/#.*$/', '', $line), " \t\"'");
if ($line !== '') $items[] = $line;
}
return $items;
}
function vv_transcode_sessions(): array {
$v = vv_conf_vars();
$stateDir = rtrim($v['STATE_DIR'] ?? STATE_DIR, '/');
$stateFile = "$stateDir/transcode_state.db";
if (!file_exists($stateFile)) return ['available' => false];
$raw = [];
foreach (file($stateFile) ?: [] as $line) {
[$k, $v] = array_pad(explode('=', trim($line), 2), 2, '');
$raw[trim($k)] = trim($v);
}
$target = $raw['current_target'] ?? '';
$lastFlip = (int)($raw['last_flip_time'] ?? 0);
$flipCount = (int)($raw['flip_count_hour'] ?? 0);
$isRamdisk = str_contains($target, 'ramdisk');
// Count active sessions: subdirs (legacy transcode) + unique hex prefixes (Live TV / Direct Stream HLS)
$ramdiskPath = '/mnt/ramdisk_transcodes/transcoding-temp';
$ramSessions = count(glob("$ramdiskPath/*/", GLOB_ONLYDIR) ?: []);
$maxAge = (int)(vv_conf_vars()['TRANSCODE_MAX_AGE'] ?? 20);
$activeFiles = 0;
$cutoff = time() - $maxAge * 60;
$flatPrefixes = [];
if (is_dir($ramdiskPath)) {
foreach (new DirectoryIterator($ramdiskPath) as $f) {
if (!$f->isFile()) continue;
if (preg_match('/^([0-9a-f]{16,})/', $f->getFilename(), $m)) {
$flatPrefixes[$m[1]] = true;
}
if ($f->getMTime() >= $cutoff) $activeFiles++;
}
}
$ramSessions += count($flatPrefixes);
// SSD path: first transcoding-temp mount that is not a RAM filesystem (tmpfs/ramfs)
$ssdPath = '';
$ssdSessions = 0;
foreach (glob('/mnt/*/transcoding-temp/', GLOB_ONLYDIR) ?: [] as $p) {
$parts = explode('/', rtrim($p, '/'));
array_pop($parts);
$mount = implode('/', $parts) ?: '/';
$fsType = trim(shell_exec('findmnt -n -o FSTYPE ' . escapeshellarg($mount) . ' 2>/dev/null') ?: '');
if ($fsType === 'tmpfs' || $fsType === 'ramfs') continue;
$ssdPath = $p;
break;
}
$ssd = ['available' => false];
if ($ssdPath) {
$ssdSessions = count(glob($ssdPath . '/*/', GLOB_ONLYDIR) ?: []);
$parts = explode('/', rtrim($ssdPath, '/'));
array_pop($parts);
$ssdMount = implode('/', $parts) ?: '/';
$ssd = vv_df($ssdMount);
}
// Ramdisk disk usage
$rd = vv_df('/mnt/ramdisk_transcodes');
// Last cleanup values from transcode management log
$lastRdFreed = null;
$lastSsdFreed = null;
$logFile = LOG_DIR . '/Orchestrators/transcode_management.log';
if (file_exists($logFile)) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach (array_reverse($lines) as $line) {
if ($lastRdFreed === null && preg_match('/Ramdisk freed:\s*(\S+)/u', $line, $m))
$lastRdFreed = $m[1];
if ($lastSsdFreed === null && preg_match('/SSD freed:\s*(\S+)/u', $line, $m))
$lastSsdFreed = $m[1];
if ($lastRdFreed !== null && $lastSsdFreed !== null) break;
}
}
return [
'available' => true,
'current_target' => $target,
'is_ramdisk' => $isRamdisk,
'flip_count_hour' => $flipCount,
'last_flip_time' => $lastFlip,
'last_flip_ago' => $lastFlip > 0 ? time() - $lastFlip : null,
'ram_sessions' => $ramSessions,
'ssd_sessions' => $ssdSessions,
'active_files' => $activeFiles,
'ramdisk' => $rd,
'ssd' => $ssd,
'last_rd_freed' => $lastRdFreed,
'last_ssd_freed' => $lastSsdFreed,
];
}
@@ -0,0 +1,514 @@
#!/bin/bash
# ==============================================================================================
# ================================= Rsync Stop =================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Stops rsync intelligently on both local and remote servers. Auto-detects
# running orchestrators and chooses the safest stop strategy. If an
# orchestrator is running, kills only the rsync subprocess so the orchestrator
# exits cleanly after finishing the current share. Use --full-stop to kill
# everything immediately.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Two Stop Modes
# Default (smart):
# Detects if an orchestrator (daily/weekly/critical sync) is running.
# If orchestrator found → kills rsync subprocess only. Orchestrator sees
# rsync exit → moves to next share or exits cleanly on its own.
# If no orchestrator → kills rsync directly (standalone rsync.sh run).
# Cleans stale lock files after kill.
# Recovers containers left stopped by interrupted rsync (local only).
#
# --full-stop (nuclear):
# Kills orchestrator first → then kills rsync.
# Orchestrator will NOT continue to next share.
# Use when everything needs to stop immediately.
#
# Orchestrator Detection
# detect_rsync_parent() scans all lock files to find which running process
# has rsync as a descendant. No hardcoded list — works for any orchestrator.
# Returns "script_name:parent_pid" if found, empty if standalone.
#
# Remote Handling
# Both local and remote handled in one run via SSH.
# Remote containers left as-is — docker_watchdog.sh handles remote recovery.
# If remote unreachable → skips remote cleanly, logs warning.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# pkill and docker require root.
#
# Single Instance Lock
# acquire_lock prevents concurrent stop attempts racing each other.
#
# Timeout Protection
# DOCKER_TIMEOUT (15s) on all docker calls — hung daemon doesn't block.
# SSH_TIMEOUT (15s) on all remote SSH calls.
#
# SIGTERM → SIGKILL Sequence
# Orchestrators receive SIGTERM first, SIGKILL only if still running after 2s.
#
# Container Recovery
# Restarts local containers left stopped by the killed rsync session.
# Remote containers deferred to docker_watchdog.sh.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# rsync_stop.sh
# Auto-detect orchestrator. Kill rsync-only or full-stop accordingly.
#
# rsync_stop.sh --full-stop
# Kill orchestrator first, then kill rsync. Nothing continues after this.
#
# rsync_stop.sh --rsync-only
# Skip container recovery. Used when called by other scripts that handle
# recovery themselves.
#
# rsync_stop.sh --dry-run
# Show what would be killed without killing anything.
#
# rsync_stop.sh --status
# Show local and remote rsync PIDs, running orchestrators, and lock files.
#
# rsync_stop.sh --full-stop --dry-run
# Preview full-stop sequence without making any changes.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
DOCKER_TIMEOUT=15
SSH_TIMEOUT=15
# ── Parse special flags before parse_args ─────────────────────────────────────────────────────
FULL_STOP=false
RSYNC_ONLY_MODE=false
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--full-stop) FULL_STOP=true ;;
--rsync-only) RSYNC_ONLY_MODE=true ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — pkill and docker require root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
# Soft IP resolution — rsync_stop continues local-only if remote unreachable
REMOTE_REACHABLE=false
REMOTE_SERVER=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
if [[ -z "$REMOTE_SERVER" ]]; then
warn "$REMOTE_SERVER_NAME — cannot resolve Tailscale IP, remote operations will be skipped"
elif timeout "$SSH_TIMEOUT" ping -c1 -W3 "$REMOTE_SERVER" &>/dev/null; then
REMOTE_REACHABLE=true
log "$REMOTE_SERVER_NAME reachable ✅"
else
warn "$REMOTE_SERVER_NAME unreachable — remote operations will be skipped"
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
[[ "$FULL_STOP" == true ]] && warn "FULL STOP mode — orchestrator + rsync will be killed"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY RSYNC STOP STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo ""
LOCAL_PIDS=$(pgrep -x rsync 2>/dev/null | tr '\n' ' ')
echo " $ICON_SYNC Local rsync PIDs: ${LOCAL_PIDS:-none}"
for lockfile in "$LOCK_DIR"/*.lock; do
[[ -f "$lockfile" ]] || continue
content=$(cat "$lockfile" 2>/dev/null)
pid="${content%%:*}"
name="${content##*:}"
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && \
echo " $ICON_RUNNING Lock: $name (PID $pid)"
done
if [[ "$REMOTE_REACHABLE" == true ]]; then
REMOTE_PIDS=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" "pgrep -x rsync || true" 2>/dev/null | tr '\n' ' ')
echo " $ICON_SYNC Remote rsync PIDs: ${REMOTE_PIDS:-none}"
else
echo " $ICON_WARN Remote: unreachable"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── ORCHESTRATOR DETECTION ────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Scans lock files to find which running process has rsync as a descendant.
# No hardcoded script names — detects any orchestrator automatically.
detect_rsync_parent() {
local rsync_pids
rsync_pids=$(pgrep -x rsync 2>/dev/null || true)
[[ -z "$rsync_pids" ]] && echo "" && return
for lockfile in "$LOCK_DIR"/*.lock; do
[[ -f "$lockfile" ]] || continue
local content pid locked_name
content=$(cat "$lockfile" 2>/dev/null)
pid="${content%%:*}"
locked_name="${content##*:}"
[[ -z "$pid" ]] && continue
! kill -0 "$pid" 2>/dev/null && continue
[[ "$locked_name" == rsync_* ]] && continue
local all_descendants
all_descendants=$(pgrep -P "$pid" 2>/dev/null || true)
while IFS= read -r rsync_pid; do
[[ -z "$rsync_pid" ]] && continue
local ppid
ppid=$(awk '/^PPid:/{print $2}' /proc/"$rsync_pid"/status 2>/dev/null || echo "")
if echo "$all_descendants" | grep -qw "$rsync_pid" 2>/dev/null || \
[[ "$ppid" == "$pid" ]]; then
echo "${locked_name}:${pid}"
return
fi
done <<< "$rsync_pids"
done
echo ""
}
detect_rsync_parent_remote() {
[[ "$REMOTE_REACHABLE" != true ]] && echo "" && return
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" bash << 'REMOTE_SCRIPT' 2>/dev/null
LOCK_DIR="/tmp/unraid_locks"
rsync_pids=$(pgrep -x rsync 2>/dev/null || true)
[[ -z "$rsync_pids" ]] && exit 0
for lockfile in "$LOCK_DIR"/*.lock; do
[[ -f "$lockfile" ]] || continue
content=$(cat "$lockfile" 2>/dev/null)
pid="${content%%:*}"
locked_name="${content##*:}"
[[ -z "$pid" ]] && continue
! kill -0 "$pid" 2>/dev/null && continue
[[ "$locked_name" == rsync_* ]] && continue
all_descendants=$(pgrep -P "$pid" 2>/dev/null || true)
while IFS= read -r rsync_pid; do
[[ -z "$rsync_pid" ]] && continue
ppid=$(awk '/^PPid:/{print $2}' /proc/"$rsync_pid"/status 2>/dev/null || echo "")
if echo "$all_descendants" | grep -qw "$rsync_pid" 2>/dev/null || \
[[ "$ppid" == "$pid" ]]; then
echo "${locked_name}:${pid}"
exit 0
fi
done <<< "$rsync_pids"
done
REMOTE_SCRIPT
}
LOCAL_ORCH=$(detect_rsync_parent)
REMOTE_ORCH=""
[[ "$REMOTE_REACHABLE" == true ]] && REMOTE_ORCH=$(detect_rsync_parent_remote)
# Determine mode
if [[ "$FULL_STOP" == true ]]; then
MODE="full-stop"
elif [[ -n "$LOCAL_ORCH" ]] || [[ -n "$REMOTE_ORCH" ]]; then
MODE="rsync-only"
[[ -n "$LOCAL_ORCH" ]] && \
warn "Local orchestrator detected: ${LOCAL_ORCH%%:*} — rsync-only mode"
[[ -n "$REMOTE_ORCH" ]] && \
warn "Remote orchestrator detected: ${REMOTE_ORCH%%:*} — rsync-only mode"
warn "Use --full-stop to also kill the orchestrator"
else
MODE="rsync-only"
log "No orchestrator detected — killing rsync directly"
fi
# ==============================================================================================
# ── Kill Orchestrators (full-stop only) ───────────────────────────────────────────────────────
# ==============================================================================================
ORCHESTRATORS_KILLED=()
REMOTE_ORCHESTRATORS_KILLED=()
kill_orchestrator() {
local script_name="$1" pid="$2"
local lockfile="$LOCK_DIR/${script_name}.lock"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would kill $script_name (PID $pid)"
return 0
fi
kill -TERM "$pid" 2>/dev/null
sleep 2
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null
sleep 1
if ! kill -0 "$pid" 2>/dev/null; then
warn "$script_name stopped (PID $pid) ✅"
rm -f "$lockfile"
return 0
else
error "Failed to kill $script_name (PID $pid)"
return 1
fi
}
if [[ "$MODE" == "full-stop" ]]; then
echo ""
echo "━━━ $ICON_STOP Kill Orchestrators ━━━"
if [[ -n "$LOCAL_ORCH" ]]; then
local_name="${LOCAL_ORCH%%:*}"
local_pid="${LOCAL_ORCH##*:}"
warn "Killing local: $local_name (PID $local_pid)"
kill_orchestrator "$local_name" "$local_pid" && \
ORCHESTRATORS_KILLED+=("$local_name")
else
log "No local orchestrator running"
fi
if [[ "$REMOTE_REACHABLE" == true ]] && [[ -n "$REMOTE_ORCH" ]]; then
remote_name="${REMOTE_ORCH%%:*}"
remote_pid="${REMOTE_ORCH##*:}"
remote_lock="$LOCK_DIR/${remote_name}.lock"
warn "Killing remote: $remote_name (PID $remote_pid)"
if [[ "$DRY_RUN" == false ]]; then
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"kill -TERM '$remote_pid' 2>/dev/null; sleep 2; \
kill -0 '$remote_pid' 2>/dev/null && kill -KILL '$remote_pid' 2>/dev/null; \
rm -f '$remote_lock'" 2>/dev/null
warn "Remote $remote_name stopped ✅"
REMOTE_ORCHESTRATORS_KILLED+=("$remote_name")
else
warn "DRY RUN — would kill remote $remote_name (PID $remote_pid)"
fi
elif [[ "$REMOTE_REACHABLE" == true ]]; then
log "No remote orchestrator running"
fi
[[ ${#ORCHESTRATORS_KILLED[@]} -gt 0 || \
${#REMOTE_ORCHESTRATORS_KILLED[@]} -gt 0 ]] && sleep 3
fi
# ==============================================================================================
# ━━━ Local Rsync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP Local Rsync ━━━"
LOCAL_KILLED=false
LOCAL_PIDS=$(pgrep -x rsync 2>/dev/null || true)
if [[ -z "$LOCAL_PIDS" ]]; then
log "No rsync processes running locally"
else
warn "Found local rsync PIDs: $(echo "$LOCAL_PIDS" | tr '\n' ' ')"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would kill local rsync"
else
pkill -x rsync 2>/dev/null && LOCAL_KILLED=true || \
warn "pkill returned non-zero — rsync may have already exited"
[[ "$LOCAL_KILLED" == true ]] && warn "Local rsync killed ✅"
fi
fi
# Clean stale rsync lock files
for lockfile in "$LOCK_DIR"/rsync_*.lock; do
[[ -f "$lockfile" ]] || continue
content=$(cat "$lockfile" 2>/dev/null)
pid="${content%%:*}"
if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then
log "Cleaning stale lock: $(basename "$lockfile")"
[[ "$DRY_RUN" == false ]] && rm -f "$lockfile"
fi
done
# ==============================================================================================
# ━━━ Remote Rsync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP Remote Rsync — $REMOTE_SERVER_NAME ━━━"
REMOTE_KILLED=false
if [[ "$REMOTE_REACHABLE" == false ]]; then
warn "Skipping — $REMOTE_SERVER_NAME unreachable"
else
REMOTE_PIDS=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" "pgrep -x rsync || true" 2>/dev/null || true)
if [[ -z "$REMOTE_PIDS" ]]; then
log "No rsync running on $REMOTE_SERVER_NAME"
else
warn "Found remote rsync PIDs: $(echo "$REMOTE_PIDS" | tr '\n' ' ')"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would kill remote rsync"
else
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" "pkill -x rsync || true" 2>/dev/null && \
REMOTE_KILLED=true || \
warn "Remote pkill returned non-zero — rsync may have already exited"
[[ "$REMOTE_KILLED" == true ]] && warn "Remote rsync killed ✅"
fi
fi
fi
# ==============================================================================================
# ━━━ Container Recovery ━━━
# ==============================================================================================
# Restart local containers left stopped by interrupted rsync.
# Remote containers left for docker_watchdog.sh to recover.
# Skipped with --rsync-only flag (called by other scripts that handle recovery themselves).
CONTAINERS_RESTARTED=()
CONTAINERS_FAILED=()
if [[ "$RSYNC_ONLY_MODE" == false ]] && \
{ [[ "$LOCAL_KILLED" == true ]] || [[ ${#ORCHESTRATORS_KILLED[@]} -gt 0 ]]; }; then
echo ""
echo "━━━ $ICON_START Container Recovery ━━━"
log "Checking profile containers for recovery..."
declare -A SEEN
ALL_CONTAINERS=()
for profile_containers in "${PROFILE_CRITICAL_CONTAINER_NAMES[@]:-}"; do
read -r -a container_list <<< "$profile_containers"
for c in "${container_list[@]:-}"; do
[[ -z "$c" ]] && continue
if [[ -z "${SEEN[$c]:-}" ]]; then
SEEN[$c]=1
ALL_CONTAINERS+=("$c")
fi
done
done
if [[ ${#ALL_CONTAINERS[@]} -eq 0 ]]; then
log "No profile containers defined — skipping recovery"
else
for c in "${ALL_CONTAINERS[@]}"; do
STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f \
'{{.State.Running}}' "$c" 2>/dev/null || echo "unknown")
case "$STATUS" in
true)
log "$c — running ✅"
;;
false)
warn "$c — stopped — restarting..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would restart $c"
else
if timeout "$DOCKER_TIMEOUT" docker start "$c" >/dev/null 2>&1; then
warn "$c restarted ✅"
CONTAINERS_RESTARTED+=("$c")
else
error "Failed to restart $c"
CONTAINERS_FAILED+=("$c")
fi
fi
;;
*)
log "$c not found locally — skipping"
;;
esac
done
fi
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RSYNC STOP SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Mode: $MODE"
echo ""
echo "$ICON_HOST Local ($MY_ID):"
[[ ${#ORCHESTRATORS_KILLED[@]} -gt 0 ]] && \
warn " Orchestrators killed: ${ORCHESTRATORS_KILLED[*]}"
if [[ "$LOCAL_KILLED" == true ]]; then
warn " Rsync killed ✅"
else
log " No rsync was running"
fi
echo "$ICON_NET Remote ($REMOTE_ID — $REMOTE_SERVER_NAME):"
if [[ "$REMOTE_REACHABLE" == false ]]; then
warn " Unreachable — skipped"
else
[[ ${#REMOTE_ORCHESTRATORS_KILLED[@]} -gt 0 ]] && \
warn " Orchestrators killed: ${REMOTE_ORCHESTRATORS_KILLED[*]}"
if [[ "$REMOTE_KILLED" == true ]]; then
warn " Rsync killed ✅"
else
log " No rsync was running"
fi
fi
[[ ${#CONTAINERS_RESTARTED[@]} -gt 0 ]] && \
warn "$ICON_CONTAINERS Containers recovered: ${CONTAINERS_RESTARTED[*]}"
[[ ${#CONTAINERS_FAILED[@]} -gt 0 ]] && \
echo "$ICON_ERROR Containers failed to restart: ${CONTAINERS_FAILED[*]}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
else
echo "$ICON_DONE Status: done ✅"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Notify if anything was actually killed or failed
if [[ "$DRY_RUN" == false ]]; then
if [[ ${#CONTAINERS_FAILED[@]} -gt 0 ]]; then
notify "Rsync stop on $(hostname) ($MY_ID) — containers failed to restart: ${CONTAINERS_FAILED[*]}" \
"Rsync Stop" "warning"
elif [[ "$LOCAL_KILLED" == true || "$REMOTE_KILLED" == true || \
${#ORCHESTRATORS_KILLED[@]} -gt 0 ]]; then
notify "Rsync stopped on $(hostname) ($MY_ID) — mode: $MODE${CONTAINERS_RESTARTED:+ — recovered: ${CONTAINERS_RESTARTED[*]}}" \
"Rsync Stop" "warning"
fi
fi
@@ -0,0 +1,232 @@
#!/bin/bash
# ==============================================================================================
# ============================= Critical Sync Maintenance ======================================
# ==============================================================================================
# Orchestrator for time-sensitive syncs that run every 30 minutes.
# Keeps the mirror current between the less frequent daily and weekly windows.
# Schedule: */30 * * * * (every 30 minutes via User Scripts plugin)
#
# ── EXECUTION ORDER ───────────────────────────────────────────────────────────────────────────
# 1. Critical-Data rsync — auth stack, NPM config, certs (containers stopped both sides)
# 2. emby-fallback rsync — dirty Emby sync (watch states, library — Emby stays running)
# 3. CRITICAL_MAINTENANCE_SCRIPTS — any scripts configured for critical window
# 4. partnership --check — read both state files, detect changes, act accordingly
#
# ── WHY EVERY 30 MINUTES ──────────────────────────────────────────────────────────────────────
# Auth stack changes (new users, proxy rules, certs) propagate within 30min ✅
# Emby watch states stay in sync — mirror users see correct playback position ✅
# Partnership state changes detected and acted on quickly ✅
# Lock prevents: daily rsync doing Critical-Data mid-critical window ✅
#
# ── RSYNC GATE ────────────────────────────────────────────────────────────────────────────────
# RSYNC_ENABLED=false → skips all syncs (global gate)
# CRITICAL_RSYNC_ENABLED=false → skips critical syncs only (per-orchestrator gate)
# partnership --check always runs regardless — state check doesn't need rsync
#
# ── LOCK BEHAVIOUR ────────────────────────────────────────────────────────────────────────────
# acquire_lock "strict" — if previous 30min run still going, skip this cycle entirely
# Critical-Data taking > 30min is a problem worth knowing about
# Strict mode prevents pile-up without waiting — log and move on ✅
#
# ── SILENT WHEN HEALTHY ───────────────────────────────────────────────────────────────────────
# Runs 48 times per day — clean runs must produce zero output ✅
# Only failures and notable events produce visible output
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# CRITICAL_RSYNC_ENABLED — enable/disable rsync section
# CRITICAL_SYNC_SHARES — shares synced every 30min (HOST*_CRITICAL_SYNC_SHARES)
# CRITICAL_MAINTENANCE_SCRIPTS — scripts run in critical window (optional)
# PARTNERSHIP_ENABLED — enable/disable partnership check
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# critical_sync_maintenance.sh — normal run
# critical_sync_maintenance.sh --dry-run — preview syncs without transferring
# critical_sync_maintenance.sh --log — verbose per-share output
# critical_sync_maintenance.sh --status — show configuration and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock "strict"
detect_hosts
resolve_remote_ip
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY CRITICAL SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_NET Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_SYNC Rsync enabled: ${RSYNC_ENABLED:-true}"
echo "$ICON_SYNC Critical enabled: ${CRITICAL_RSYNC_ENABLED:-false}"
echo "$ICON_SHIELD Partnership: ${PARTNERSHIP_ENABLED:-false}"
echo ""
echo "━━━ Critical Sync Shares ━━━"
if [[ ${#CRITICAL_SYNC_SHARES[@]} -eq 0 ]]; then
warn " No CRITICAL_SYNC_SHARES configured"
else
for share in "${CRITICAL_SYNC_SHARES[@]}"; do
[[ -z "$share" ]] && continue
SHARE_PATH="${share%%|*}"
SHARE_PROFILE="${share##*|}"
SHARE_NAME=$(basename "$SHARE_PATH")
[[ "$SHARE_PATH" == "$SHARE_PROFILE" ]] && \
echo " $ICON_SYNC $SHARE_NAME — no profile" || \
echo " $ICON_SYNC $SHARE_NAME — profile: $SHARE_PROFILE"
done
fi
echo ""
echo "━━━ Critical Maintenance Scripts ━━━"
if [[ ${#CRITICAL_MAINTENANCE_SCRIPTS[@]} -eq 0 ]]; then
echo " None configured"
else
for entry in "${CRITICAL_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$entry" || "$entry" == \#* ]] && continue
echo " $ICON_GEAR $(basename "${entry%% *}")"
done
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Critical Shares Sync ━━━
# ==============================================================================================
START=$(date +%s)
RSYNC_OK=false
PASS=()
FAIL=()
log "$ICON_SYNC Critical shares (${#CRITICAL_SYNC_SHARES[@]}): $(for s in "${CRITICAL_SYNC_SHARES[@]}"; do printf '%s ' "$(basename "${s%%|*}")"; done)"
[[ ${#CRITICAL_MAINTENANCE_SCRIPTS[@]} -gt 0 ]] && \
log "$ICON_GEAR Maintenance scripts: $(for s in "${CRITICAL_MAINTENANCE_SCRIPTS[@]}"; do printf '%s ' "$(basename "${s%% *}")"; done)"
if ! check_rsync_enabled "CRITICAL"; then
echo "Critical rsync disabled — skipping sync, running partnership check only"
elif [[ ${#CRITICAL_SYNC_SHARES[@]} -eq 0 ]]; then
warn "CRITICAL_RSYNC_ENABLED=true but CRITICAL_SYNC_SHARES is empty for $MY_ID"
warn "Check HOST*_CRITICAL_SYNC_SHARES in host*.conf"
else
echo "Critical sync — $MY_ID → $REMOTE_ID — $(date '+%H:%M:%S')"
# Build dry-run flag to pass through
RSYNC_DRY=""
[[ "$DRY_RUN" == true ]] && RSYNC_DRY="--dry-run"
for share in "${CRITICAL_SYNC_SHARES[@]}"; do
[[ -z "$share" ]] && continue
# Parse optional profile flag: "/path/to/share|profile-name"
SHARE_PATH="${share%%|*}"
SHARE_PROFILE="${share##*|}"
SHARE_NAME=$(basename "$SHARE_PATH")
SHARE_START=$(date +%s)
if [[ "$SHARE_PATH" == "$SHARE_PROFILE" ]]; then
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$SHARE_PATH" $RSYNC_DRY
else
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$SHARE_PATH" \
--profile="$SHARE_PROFILE" $RSYNC_DRY
fi
RSYNC_EXIT=$?
SHARE_DUR=$(format_duration $(( $(date +%s) - SHARE_START )))
if [[ "$RSYNC_EXIT" -eq 0 ]]; then
PASS+=("$SHARE_NAME")
log "$SHARE_NAME — done in $SHARE_DUR ✅"
RSYNC_OK=true
else
FAIL+=("$SHARE_NAME")
error "$SHARE_NAME — failed after $SHARE_DUR (exit $RSYNC_EXIT)"
fi
done
fi
# ==============================================================================================
# ━━━ Critical Maintenance Scripts ━━━
# ==============================================================================================
if [[ ${#CRITICAL_MAINTENANCE_SCRIPTS[@]} -gt 0 ]]; then
for script_entry in "${CRITICAL_MAINTENANCE_SCRIPTS[@]}"; do
[[ -z "$script_entry" || "$script_entry" == \#* ]] && continue
SCRIPT_PATH="$SCRIPT_DIR/../${script_entry%% *}"
SCRIPT_ARGS="${script_entry#* }"
[[ "$SCRIPT_ARGS" == "$script_entry" ]] && SCRIPT_ARGS=""
[[ "$DRY_RUN" == true ]] && SCRIPT_ARGS="$SCRIPT_ARGS --dry-run"
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
if [[ ! -f "$SCRIPT_PATH" ]]; then
warn "$SCRIPT_NAME not found at $SCRIPT_PATH — skipping"
continue
fi
log "Running: $SCRIPT_NAME"
bash "$SCRIPT_PATH" $SCRIPT_ARGS
EXIT_CODE=$?
[[ "$EXIT_CODE" -ne 0 ]] && \
warn "$SCRIPT_NAME exited with code $EXIT_CODE"
done
fi
# ==============================================================================================
# ━━━ Partnership Check ━━━
# ==============================================================================================
if [[ "${PARTNERSHIP_ENABLED:-false}" == true ]]; then
PARTNER_DRY=""
[[ "$DRY_RUN" == true ]] && PARTNER_DRY="--dry-run"
if [[ "$RSYNC_OK" == true ]]; then
bash "$SCRIPT_DIR/../Partnership/partnership_manager.sh" \
--check --remote-seen $PARTNER_DRY
else
bash "$SCRIPT_DIR/../Partnership/partnership_manager.sh" \
--check --remote-unseen $PARTNER_DRY
fi
else
echo "Partnership disabled — skipping check"
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
END=$(date +%s)
DURATION=$(format_duration $(( END - START )))
# Silent when healthy — only show summary if there were failures or notable events
if [[ ${#FAIL[@]} -gt 0 ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY CRITICAL SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $DURATION"
[[ ${#PASS[@]} -gt 0 ]] && echo "Synced: ${PASS[*]}"
echo "$ICON_ERROR Failed: ${FAIL[*]}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
notify "Critical sync failed on $(hostname) ($MY_ID) — ${FAIL[*]}" \
"Critical Sync" "warning"
exit 1
else
echo "Critical sync complete — $MY_ID — ${DURATION} — ${#PASS[@]} share(s)"
fi
exit 0
@@ -0,0 +1,299 @@
#!/bin/bash
# ==============================================================================================
# ============================= Recreate Shares ================================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Creates share directories on the correct disks after a fresh unRAID install
# or disk rebuild. Reads all .cfg files from /boot/config/shares/ and creates
# the corresponding directories on each disk listed in the shareInclude setting.
# The array must be started before running — /mnt/user must be mounted.
#
# Typically run on HOST2 after a full disk replacement or fresh install where
# share folders were lost but /boot/config/shares/*.cfg files were restored.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each share .cfg file:
# 1. Reads shareInclude= to determine which disks own this share
# 2. Creates /mnt/diskN/ShareName/ on each listed disk if it doesn't exist
# 3. Places a .recovery marker file in /mnt/user/ShareName/ via the union filesystem
#
# .recovery Marker
# Signals to rsync.sh that this is a fresh share with no existing data.
# rsync.sh checks for .recovery before running with --delete:
# .recovery present → rsync WITHOUT --delete (new files only, nothing removed)
# .recovery absent → rsync WITH --delete (normal mirror mode)
#
# Self-cleaning: after the first successful rsync the source side has no .recovery
# file, so the second nightly run deletes it from the mirror, restoring normal
# --delete behaviour automatically. No manual cleanup needed.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Single Instance Lock
# acquire_lock prevents duplicate runs placing duplicate .recovery markers.
#
# Root Required
# mkdir on /mnt/diskN requires root.
#
# Array Mount Check
# Exits cleanly if the array is not started — /mnt/user not mounted means
# all share operations would fail silently.
#
# Per-Disk Guards
# Missing disks are skipped with a warning and the rest continue — a single
# offline disk does not abort the full run.
#
# Empty Config Guard
# Warns if no share .cfg files are found — catches the case where
# /boot/config/shares/ was not restored.
#
# Notification Validated
# platform_require_cmd confirms the notify script is present before use.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# recreate_shares.sh
# Read all .cfg files, create share directories, place .recovery markers.
#
# recreate_shares.sh --dry-run
# Show what directories and markers would be created. No changes.
#
# recreate_shares.sh --log
# Verbose output per share and per disk.
#
# recreate_shares.sh --status
# Show which shares exist in config and which directories exist on disk.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../../../load_config.sh"
parse_args "$@"
SHARE_CFG_DIR="/boot/config/shares"
MARKER_FILE=".recovery"
CREATED=()
SKIPPED=()
FAILED=()
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — mkdir on /mnt/diskN requires root"
exit 1
fi
platform_require_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
acquire_lock
# detect_hosts() sets MY_ID — used in summary
detect_hosts
log "$ICON_GEAR Config: cfg-dir=${SHARE_CFG_DIR} marker=${MARKER_FILE}"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no directories or markers will be created"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_DISK Cfg dir: $SHARE_CFG_DIR"
echo "$ICON_DISK Marker: $MARKER_FILE"
echo ""
if ! mountpoint -q /mnt/user; then
warn "Array: NOT STARTED — /mnt/user not mounted"
else
echo " Array: started ✅"
fi
echo ""
echo "━━━ Share Config Files ━━━"
CFG_COUNT=0
for cfg in "$SHARE_CFG_DIR"/*.cfg; do
[[ ! -f "$cfg" ]] && continue
(( CFG_COUNT++ ))
SHARE_NAME=$(basename "$cfg" .cfg)
INCLUDE=$(grep '^shareInclude=' "$cfg" 2>/dev/null | cut -d'"' -f2)
MARKER_EXISTS="no"
[[ -f "/mnt/user/${SHARE_NAME}/${MARKER_FILE}" ]] && MARKER_EXISTS="yes"
echo " $ICON_DISK $SHARE_NAME — disks: ${INCLUDE:-none} — recovery marker: $MARKER_EXISTS"
done
[[ "$CFG_COUNT" -eq 0 ]] && warn "No .cfg files found in $SHARE_CFG_DIR"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight ━━━
# ==============================================================================================
# Array must be started — /mnt/user must be mounted
if ! mountpoint -q /mnt/user; then
error "Array is not started — /mnt/user is not mounted"
warn "Start the array in the unRAID UI before running this script"
notify "Recreate shares failed on $(hostname) — array is not started" \
"Recreate Shares" "warning"
exit 1
fi
log "Array is started — /mnt/user is mounted ✅"
# Check share cfg directory exists and has files
if [[ ! -d "$SHARE_CFG_DIR" ]]; then
error "Share config directory not found: $SHARE_CFG_DIR"
error "Is /boot mounted? Is this the correct server?"
exit 1
fi
CFG_FILES=("$SHARE_CFG_DIR"/*.cfg)
if [[ ! -f "${CFG_FILES[0]}" ]]; then
warn "No share .cfg files found in $SHARE_CFG_DIR"
warn "Nothing to recreate — are share configs present on /boot?"
exit 0
fi
log "Found ${#CFG_FILES[@]} share .cfg file(s) in $SHARE_CFG_DIR"
# ==============================================================================================
# ━━━ Recreate Shares ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_DISK Recreate Shares — $MY_ID ━━━"
echo ""
for cfg in "${CFG_FILES[@]}"; do
[[ ! -f "$cfg" ]] && continue
SHARE_NAME=$(basename "$cfg" .cfg)
INCLUDE=$(grep '^shareInclude=' "$cfg" 2>/dev/null | cut -d'"' -f2)
echo "━━━ $ICON_DISK $SHARE_NAME ━━━"
if [[ -z "$INCLUDE" ]]; then
warn "$SHARE_NAME — no shareInclude in .cfg — skipping"
SKIPPED+=("$SHARE_NAME")
echo ""
continue
fi
log "$SHARE_NAME — disks: $INCLUDE"
SHARE_OK=true
DIRS_CREATED=0
DIRS_EXISTED=0
# Create directory on each listed disk
IFS=',' read -ra DISKS <<< "$INCLUDE"
for disk in "${DISKS[@]}"; do
disk="${disk// /}" # trim whitespace
[[ -z "$disk" ]] && continue
DISK_MOUNT="/mnt/${disk}"
DISK_PATH="${DISK_MOUNT}/${SHARE_NAME}"
# Verify disk is mounted
if ! mountpoint -q "$DISK_MOUNT" 2>/dev/null; then
warn "$disk not mounted — skipping $DISK_PATH"
continue
fi
if [[ -d "$DISK_PATH" ]]; then
log "$disk/$SHARE_NAME already exists — skipping"
(( DIRS_EXISTED++ ))
else
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create: $DISK_PATH"
(( DIRS_CREATED++ ))
elif mkdir -p "$DISK_PATH"; then
log "Created: $DISK_PATH ✅"
(( DIRS_CREATED++ ))
else
error "Failed to create: $DISK_PATH"
SHARE_OK=false
fi
fi
done
# Place .recovery marker via /mnt/user (union filesystem)
MARKER_PATH="/mnt/user/${SHARE_NAME}/${MARKER_FILE}"
if [[ "$SHARE_OK" == true ]]; then
if [[ -f "$MARKER_PATH" ]]; then
log ".recovery marker already exists in $SHARE_NAME"
CREATED+=("$SHARE_NAME")
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would place marker: $MARKER_PATH"
CREATED+=("$SHARE_NAME")
elif touch "$MARKER_PATH" 2>/dev/null; then
log "Marker placed: $MARKER_PATH ✅"
CREATED+=("$SHARE_NAME")
else
warn "$SHARE_NAME — could not place .recovery marker"
warn "Share directory may not be visible via /mnt/user yet"
warn "Try: touch /mnt/user/${SHARE_NAME}/.recovery manually after verifying share"
SKIPPED+=("$SHARE_NAME")
fi
else
FAILED+=("$SHARE_NAME")
fi
[[ "$DIRS_CREATED" -gt 0 ]] && warn "$SHARE_NAME — created $DIRS_CREATED dir(s) on disk"
[[ "$DIRS_EXISTED" -gt 0 ]] && log "$SHARE_NAME — $DIRS_EXISTED dir(s) already existed"
echo ""
done
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo "━━━━━ $ICON_SUMMARY RECREATE SHARES SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo ""
[[ ${#CREATED[@]} -gt 0 ]] && warn "Created + marked: ${CREATED[*]}"
[[ ${#SKIPPED[@]} -gt 0 ]] && warn "Skipped: ${SKIPPED[*]}"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
echo ""
echo " Created: ${#CREATED[@]}"
echo " Skipped: ${#SKIPPED[@]}"
echo " Failed: ${#FAILED[@]}"
if [[ "$DRY_RUN" == false && ${#CREATED[@]} -gt 0 ]]; then
echo ""
echo "━━━ Next Steps ━━━"
echo " 1. $ICON_HEALTH Verify shares are visible in unRAID UI"
echo " 2. $ICON_SYNC Run initial rsync push from HOST1 → HOST2"
echo " rsync.sh will detect .recovery markers and skip --delete"
echo " Normal --delete mode restores automatically on second nightly run"
echo " 3. $ICON_GEAR No manual config changes needed — markers self-clean ✅"
fi
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -gt 0 ]]; then
echo "$ICON_ERROR Status: completed with failures"
notify "Recreate shares failed on $(hostname) ($MY_ID) — failed: ${FAILED[*]}" \
"Recreate Shares" "warning"
exit 1
else
echo "$ICON_DONE Status: done — ${#CREATED[@]} created, ${#SKIPPED[@]} skipped"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,335 @@
#!/bin/bash
# ==============================================================================================
# ================================= Server Reboot ==============================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Gracefully reboots the unRAID server with pre-flight checks, user warnings,
# clean service shutdown, and disk sync. Use instead of raw /sbin/reboot —
# gives users warning time and ensures services stop cleanly before the kernel
# drops.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Shutdown Sequence
# 1. Pre-flight warnings — rsync, mover, active Emby sessions (warn, not block)
# 2. Wall message to all logged-in terminal users
# 3. unRAID dashboard notification
# 4. Wait REBOOT_SLEEP seconds — users time to save work
# 5. array_stopping.sh — user scripts, rsync, mover, containers (verified stop)
# 6. Graceful VM shutdown via virsh — ACPI signal, then wait REBOOT_VM_WAIT
# 7. Stop libvirt (VM Manager)
# 8. sync — filesystem buffers flushed to disk
# 9. /sbin/reboot
#
# Pre-flight Warnings (informational — do not block)
# rsync running → partial files possible if mid-transfer
# mover running → files may be left mid-move on cache or array
# Emby sessions → active streams/transcodes interrupted
# Warnings do not block the reboot — you called this script, you know.
#
# VM Graceful Shutdown
# virsh shutdown sends the ACPI power button signal — same as pressing the
# physical power button. VM gets a chance to flush buffers and shut down.
# After REBOOT_VM_WAIT seconds, libvirt stops anyway — reboot takes priority.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root Required
# /sbin/reboot requires root.
#
# Single Instance Lock
# acquire_lock prevents concurrent reboot calls.
#
# Host Identity in All Messages
# detect_hosts() sets MY_ID — wall and notifications show which server is
# rebooting. Critical on a two-server setup.
#
# sync Before Reboot
# filesystem buffers flushed to disk before reboot command.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# REBOOT_SLEEP
# Seconds between warning and shutdown sequence start. (default: 30)
#
# REBOOT_VM_WAIT
# Seconds to wait for VMs to shut down gracefully. (default: 30)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# server_reboot.sh
# Run pre-flight, warn users, stop services cleanly, then reboot.
#
# server_reboot.sh --dry-run
# Walk through the entire shutdown sequence without stopping anything or rebooting.
#
# server_reboot.sh --status
# Show running processes that would be affected: rsync, mover, VMs, containers.
#
# server_reboot.sh --reason="maintenance"
# Include reason in wall message and notification. Defaults to "manual".
#
# server_reboot.sh --log
# Verbose output — show each step of the shutdown sequence.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Parse --reason flag before parse_args ─────────────────────────────────────────────────────
REBOOT_REASON="manual"
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--reason=*) REBOOT_REASON="${arg#--reason=}" ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root — reboot requires root"
exit 1
fi
validate_int REBOOT_SLEEP "$REBOOT_SLEEP"
acquire_lock
detect_hosts
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made, no reboot will occur"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY REBOOT STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_REBOOT Delay: ${REBOOT_SLEEP}s"
echo "$ICON_GEAR VM wait: ${REBOOT_VM_WAIT:-30}s"
echo "$ICON_GEAR Reason: $REBOOT_REASON"
echo ""
echo "━━━ Active Processes ━━━"
pgrep -x rsync >/dev/null 2>&1 && \
warn " rsync: RUNNING — partial files if rebooted now" || \
log " rsync: not running"
platform_is_mover_running && \
warn " mover: RUNNING — files may be left mid-move" || \
log " mover: not running"
if command -v virsh >/dev/null 2>&1; then
VM_COUNT=$(virsh list --name 2>/dev/null | grep -c "." || echo 0)
[[ "$VM_COUNT" -gt 0 ]] && \
warn " VMs: $VM_COUNT running — will be gracefully shut down" || \
log " VMs: none running"
fi
if command -v docker >/dev/null 2>&1; then
CONTAINER_COUNT=$(docker ps -q 2>/dev/null | wc -l || echo 0)
log " Docker: $CONTAINER_COUNT container(s) running"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Pre-flight Warnings ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
WARNINGS=()
# rsync check — partial files if killed mid-transfer
if pgrep -x rsync >/dev/null 2>&1; then
RSYNC_PIDS=$(pgrep -x rsync | tr '\n' ' ')
warn "rsync is running (PIDs: $RSYNC_PIDS) — partial files possible"
warn "Consider: rsync_stop.sh before rebooting"
WARNINGS+=("rsync running")
fi
# mover check — files may be left mid-move
if platform_is_mover_running; then
warn "Mover is running — files may be left mid-move on cache or array"
warn "Consider: mover_stop.sh before rebooting"
WARNINGS+=("mover running")
fi
# Emby sessions check — active streams interrupted
if [[ -n "${EMBY_URL:-}" ]] && [[ -n "${EMBY_API_KEY:-}" ]]; then
ACTIVE_STREAMS=$(curl -sf --max-time 5 \
-H "X-Emby-Token: $EMBY_API_KEY" \
"${EMBY_URL}/Sessions" 2>/dev/null | \
grep -c "NowPlayingItem" 2>/dev/null || echo 0)
ACTIVE_STREAMS="${ACTIVE_STREAMS//[^0-9]/}"
if [[ "${ACTIVE_STREAMS:-0}" -gt 0 ]]; then
warn "$ACTIVE_STREAMS active Emby stream(s) — will be interrupted"
WARNINGS+=("${ACTIVE_STREAMS} Emby sessions")
fi
fi
CONTAINER_COUNT=$(docker ps -q 2>/dev/null | wc -l || echo 0)
log "$ICON_CONTAINERS Docker: ${CONTAINER_COUNT} container(s) running"
if is_vm_manager_enabled && command -v virsh >/dev/null 2>&1; then
VM_COUNT=$(virsh list --name 2>/dev/null | grep -c "." || echo 0)
log "$ICON_GEAR VMs: ${VM_COUNT} running"
fi
if [[ ${#WARNINGS[@]} -eq 0 ]]; then
log "Pre-flight clean — no active processes to warn about"
else
warn "Proceeding with reboot despite warnings — ${WARNINGS[*]}"
fi
# ==============================================================================================
# ━━━ Notify and Wait ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_REBOOT Reboot Sequence — $MY_ID ━━━"
echo " Reason: $REBOOT_REASON"
echo " Delay: ${REBOOT_SLEEP}s"
echo " Dry Run: $DRY_RUN"
echo ""
START=$(date +%s)
if [[ "$REBOOT_SLEEP" -gt 0 ]]; then
# Wall message — terminal users
wall "$ICON_WARN $MY_ID ($LOCAL_SERVER_NAME) rebooting in ${REBOOT_SLEEP}s — reason: $REBOOT_REASON. Save your work now."
# unRAID notification — dashboard
if [[ "$DRY_RUN" == false ]]; then
notify "$MY_ID ($LOCAL_SERVER_NAME) rebooting in ${REBOOT_SLEEP}s — reason: $REBOOT_REASON${WARNINGS:+ — warnings: ${WARNINGS[*]}}" \
"Server Reboot" "warning"
fi
warn "Waiting ${REBOOT_SLEEP}s before shutdown sequence..."
if [[ "$DRY_RUN" == false ]]; then
sleep "$REBOOT_SLEEP"
else
warn "DRY RUN — skipping sleep"
fi
fi
# ==============================================================================================
# ━━━ Array Stop Orchestrator ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_STOP Array Stop Orchestrator ━━━"
ARRAY_STOP_SCRIPT="$SCRIPT_DIR/../Orchestrators/array_stopping.sh"
if [[ ! -f "$ARRAY_STOP_SCRIPT" ]]; then
warn "array_stopping.sh not found — skipping orchestrated stop"
elif [[ "$DRY_RUN" == true ]]; then
bash "$ARRAY_STOP_SCRIPT" --dry-run
else
if bash "$ARRAY_STOP_SCRIPT"; then
log "Array stop complete ✅"
else
warn "array_stopping.sh reported failures — proceeding with reboot"
fi
fi
# ==============================================================================================
# ━━━ Graceful VM Shutdown ━━━
# ==============================================================================================
if is_vm_manager_enabled && command -v virsh >/dev/null 2>&1; then
VM_LIST=$(virsh list --name 2>/dev/null | grep -v "^$" || true)
if [[ -n "$VM_LIST" ]]; then
echo ""
echo "━━━ $ICON_GEAR Graceful VM Shutdown ━━━"
while IFS= read -r vm; do
[[ -z "$vm" ]] && continue
warn "Sending ACPI shutdown to VM: $vm"
if [[ "$DRY_RUN" == false ]]; then
virsh shutdown "$vm" >/dev/null 2>&1 || true
else
warn "DRY RUN — would virsh shutdown $vm"
fi
done <<< "$VM_LIST"
if [[ "$DRY_RUN" == false ]]; then
VM_WAIT="${REBOOT_VM_WAIT:-30}"
log "Waiting ${VM_WAIT}s for VMs to shut down..."
sleep "$VM_WAIT"
fi
else
log "VM Manager enabled but no VMs running — skipping shutdown"
fi
else
log "VM Manager not enabled — skipping VM shutdown"
fi
# ==============================================================================================
# ━━━ Stop VM Manager ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Stop VM Manager ━━━"
if ! is_vm_manager_enabled; then
log "VM Manager not enabled — skipping"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop VM Manager (libvirt)"
else
if platform_stop_service libvirt; then
warn "VM Manager stopped ✅"
else
warn "VM Manager stop returned non-zero — may already be stopped"
fi
fi
# ==============================================================================================
# ━━━ Sync Disks ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_DISK Sync Disks ━━━"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would sync filesystem buffers"
else
sync
log "Filesystem buffers flushed ✅"
fi
# ==============================================================================================
# ━━━ Reboot ━━━
# ==============================================================================================
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY REBOOT SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_REBOOT Reason: $REBOOT_REASON"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ ${#WARNINGS[@]} -gt 0 ]] && warn "Warnings: ${WARNINGS[*]}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — sequence complete, no reboot executed"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
else
warn "$ICON_REBOOT Rebooting $MY_ID now..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
/sbin/reboot
fi
@@ -0,0 +1,557 @@
#!/bin/bash
# ==============================================================================================
# ============================= Partnership Offboard ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Cleanly ends a partnership. Role is detected automatically — run on either server.
# Owner path runs the full sequence including remote cleanup and final sync.
# Mirror path handles the local side and signals the owner to complete its own cleanup.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# OWNER PATH (10 steps)
# Step 1: Stop rsync — halt any running sync before state changes
# Step 2: Final sync — mirror leaves with current Critical-Data state
# Step 3: Reconfigure WebUIs — mirror's auth WebUIs → localhost
# Step 4: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
# Step 5: Local cleanup — remove fallback coverage containers + appdata
# Step 6: Restart own stack — bring up owner's own parked containers
# Step 7: Remote cleanup — remove auth/arr stack + fallback containers from mirror
# Step 8: Restart mirror — bring up mirror's own parked containers
# Step 9: Revocation — Emby admin, SSH keys
# Step 10: Write state — INACTIVE locally + pushed to mirror, mirror blocklisted
# Tailscale — grace window then device removal (after state written)
#
# MIRROR PATH (8 steps)
# Step 1: Stop rsync — halt any running sync
# Step 2: Reconfigure WebUIs — local auth WebUIs → localhost
# Step 3: Remote stack clean — remove owner-deployed containers locally (auth/arr stack)
# Step 4: Fallback cleanup — remove fallback coverage containers
# Step 5: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
# Step 6: Revoke Emby admin — remove own admin account from local Emby instance
# Step 7: Restart own stack — bring up own parked containers
# Step 8: SSH revocation — revoke keys both directions, write state, signal owner
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST*_PARTNERSHIP_AUTH_STACK
# Auth container XMLs to push during onboard — used on offboard to identify what
# to remove. Owner's PARTNERSHIP_AUTH_STACK determines which containers get removed
# from the mirror on both owner-initiated and mirror-initiated offboard.
#
# HOST*_PARTNERSHIP_ARR_STACK
# Arr container XMLs — same cleanup logic as auth stack.
#
# HOST*_PARTNERSHIP_SERVICES_STACK
# Shared services XMLs (Emby, Jellyfin, Seerr, SeerrFin) — same cleanup logic.
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# Partnership/partnership_offboard.sh
# Full offboard — role detected automatically
#
# Partnership/partnership_offboard.sh --dry-run
# Preview all steps without executing
#
# Partnership/partnership_offboard.sh --log
# Verbose per-step output
#
# Partnership/partnership_offboard.sh --reason=<string>
# Tag the offboard reason in state file and blocklist (default: manual)
# Called by partnership_manager.sh --offboard (reason passed through)
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15
source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()
for arg in "$@"; do
case "$arg" in
--reason=*) REASON="${arg#--reason=}" ;;
*) FILTERED_ARGS+=("$arg") ;;
esac
done
parse_args "${FILTERED_ARGS[@]}"
# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
detect_hosts
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
OWNER="${!OWNER_ID}"
MIRROR="${!MIRROR_ID}"
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
LOCAL_STATE_FILE="${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db"
REMOTE_STATE_FILE="${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db"
OWNER_STATE_FILE="${STATE_DIR}/partnership_${OWNER}.db"
MIRROR_STATE_FILE="${STATE_DIR}/partnership_${MIRROR}.db"
OFFLINE_COUNTER="${STATE_DIR}/partnership_offline_days.db"
acquire_lock "strict"
trap _pm_trap_restart_stopped EXIT
# Check already offboarded
if [[ -f "$LOCAL_STATE_FILE" ]]; then
CURRENT_STATE=$(read_state_file "$LOCAL_STATE_FILE" "state")
if [[ "$CURRENT_STATE" == "INACTIVE" ]]; then
warn "Partnership already INACTIVE — use partnership_manager.sh --status to verify both servers agree"
exit 0
fi
fi
START=$(date +%s)
echo ""
echo "━━━ $ICON_FALLBACK Partnership Offboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
echo ""
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
echo " Reason: $REASON"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
#
# Mirror-initiated path only. Called before start_own_stack so Emby is still running.
# Uses local EMBY_API_KEY and the mirror's own short name as the username to delete.
# ==============================================================================================
revoke_local_emby_admin() {
local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
local emby_url="http://127.0.0.1:${emby_port}"
echo ""
echo "━━━ $ICON_EMBY Emby Admin Revocation ━━━"
if [[ "${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}" != true ]]; then
log "PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping"
return 0
fi
if [[ -z "${EMBY_API_KEY:-}" ]]; then
warn "EMBY_API_KEY not set — skipping local Emby admin revocation"
return 1
fi
# The account to revoke is this server's own short name (the mirror user's account)
local username="${PARTNERSHIP_EMBY_ADMIN_USER:-$(derive_short_name "$LOCAL_SERVER_NAME")}"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would delete Emby admin '$username' at $emby_url"
return 0
fi
log "Looking up Emby user '$username' at $emby_url..."
local users_json user_id
users_json=$(curl -sf --max-time 15 \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
"${emby_url}/Users" 2>/dev/null)
user_id=$(echo "$users_json" | \
grep -o "\"Id\":\"[^\"]*\"[^}]*\"Name\":\"${username}\"" | \
grep -o '"Id":"[^"]*"' | cut -d'"' -f4 | head -1)
if [[ -z "$user_id" ]]; then
warn "Emby user '$username' not found at $emby_url — may already be removed"
return 0
fi
local del_code
del_code=$(curl -sf --max-time 15 -w "%{http_code}" -o /dev/null \
-X DELETE \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
"${emby_url}/Users/${user_id}" 2>/dev/null)
if [[ "$del_code" == "200" ]] || [[ "$del_code" == "204" ]] || [[ "$del_code" == "404" ]]; then
echo "Emby admin '$username' removed ✅"
else
warn "Failed to delete Emby user '$username' (HTTP $del_code) — remove manually"
fi
}
# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$AM_MIRROR" == true ]]; then
warn "$MIRROR_ID ($MIRROR) is initiating offboard"
warn "Owner ($OWNER) will see INACTIVE state on its next --check cycle and finalize"
if [[ "$DRY_RUN" == false ]]; then
echo ""
echo "You have 10 seconds to cancel (Ctrl+C)..."
sleep 10
fi
OWNER_IP=$(resolve_tailscale_ip "$OWNER")
OWNER_REACHABLE=false
[[ -n "$OWNER_IP" ]] && OWNER_REACHABLE=true
STEP_STOP_RSYNC_OK=true
STEP_WEBUI_OK=true
STEP_STACK_CLEANUP_OK=true
STEP_FALLBACK_CLEANUP_OK=true
STEP_DISABLE_RSYNC_OK=true
STEP_EMBY_OK=true
SSH_REVOKE_REMOTE_OK=false
SSH_REVOKE_LOCAL_OK=false
# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━"
if [[ "$DRY_RUN" == false ]]; then
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
echo "Rsync stopped ✅"
else
warn "DRY RUN — would stop rsync"
fi
# ── Step 2: Reconfigure local WebUIs → localhost ──────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 2/8 — Reconfigure Local WebUIs → localhost ━━━"
reconfigure_local_webuis "localhost" || STEP_WEBUI_OK=false
# ── Step 3: Remove owner-deployed containers (auth/arr stack) locally ─────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 3/8 — Remove Owner-Deployed Containers ━━━"
if [[ "$OWNER_REACHABLE" == true ]]; then
cleanup_deployed_stack_locally "$OWNER_IP" "$OWNER_SSH_KEY" || STEP_STACK_CLEANUP_OK=false
else
warn "Owner unreachable — cannot read deployed stack list"
warn "Auth/arr containers will remain — remove manually or re-run when owner is reachable"
STEP_STACK_CLEANUP_OK=false
fi
# ── Step 4: Remove fallback coverage containers ───────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━"
cleanup_partner_containers || STEP_FALLBACK_CLEANUP_OK=false
# ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━"
if [[ "$DRY_RUN" == false ]]; then
update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \
warn "CRITICAL_RSYNC_ENABLED=false ✅" || \
{ warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; }
else
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
fi
# ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────
revoke_local_emby_admin || STEP_EMBY_OK=false
# ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────
start_own_stack
# ── Step 8: SSH key revocation, write state, signal owner ────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━"
do_ssh_key_revocation "${OWNER_IP:-}"
NOW=$(date '+%Y-%m-%d %H:%M:%S')
if [[ "$DRY_RUN" == false ]]; then
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
echo "Local state: INACTIVE ✅"
add_to_blocklist "$OWNER" "$REASON"
else
warn "DRY RUN — would write INACTIVE state and blocklist $OWNER"
fi
if [[ "$OWNER_REACHABLE" == true ]]; then
push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$OWNER_SSH_KEY"
notify "Partnership offboard requested by $MIRROR — $OWNER will finalise on next check" \
"Partnership" "normal"
else
warn "$OWNER unreachable — state written locally, owner will see it when reachable"
fi
# ── Summary ───────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Mirror) ━━━━━"
echo " Mirror: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Owner: $OWNER_ID ($OWNER)"
echo " Reason: $REASON"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
_revoke_status() {
if [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
echo "both directions ✅"
elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
echo "local only ✅ — remote failed (revoke manually on $OWNER)"
else
echo "⚠️ failed — check warnings above"
fi
}
echo " Step 1 — Stop rsync: $(_ok "$STEP_STOP_RSYNC_OK")"
echo " Step 2 — WebUIs: $(_ok "$STEP_WEBUI_OK")"
echo " Step 3 — Stack cleanup: $(_ok "$STEP_STACK_CLEANUP_OK")"
echo " Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")"
echo " Step 5 — Disable sync: $(_ok "$STEP_DISABLE_RSYNC_OK")"
echo " Step 6 — Emby revoke: $(_ok "$STEP_EMBY_OK")"
echo " Step 7 — Own stack: started"
echo " Step 8 — Keys revoked: $(_revoke_status)"
echo ""
echo " State: INACTIVE ✅"
echo " Blocklist: $OWNER blocked ✅"
echo " Owner: will finalise + final sync on next --check"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
warn "$ICON_DONE DONE — mirror separation complete ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
warn "Offboarding $MIRROR_ID ($MIRROR) from partnership"
warn "Final sync will run — mirror leaves with current state"
if [[ "$DRY_RUN" == false ]]; then
echo ""
echo "You have 10 seconds to cancel (Ctrl+C)..."
sleep 10
echo "Proceeding..."
fi
WEBUI_FAILURES=0
STEP_STOP_OK=true
STEP_SYNC_OK=true
SSH_REVOKE_REMOTE_OK=false
SSH_REVOKE_LOCAL_OK=false
# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━"
if [[ "$DRY_RUN" == false ]]; then
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || STEP_STOP_OK=false
log "Rsync stopped ✅"
else
warn "DRY RUN — would stop rsync"
fi
# ── Step 2: Final sync ────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Step 2/10 — Final Sync ━━━"
do_final_sync || STEP_SYNC_OK=false
# ── Step 3: Reconfigure mirror WebUIs → localhost ─────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 3/10 — Reconfigure Mirror WebUIs → localhost ━━━"
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
MIRROR_REACHABLE=false
[[ -n "$MIRROR_IP" ]] && MIRROR_REACHABLE=true
if [[ "$MIRROR_REACHABLE" == true ]]; then
for entry in "${PARTNERSHIP_AUTH_WEBUIS[@]}"; do
[[ -z "$entry" ]] && continue
container="${entry%%|*}"
port="${entry##*|}"
reconfigure_webui "$container" "$port" "localhost" \
"$MIRROR_SSH_KEY" "$MIRROR_IP" "$MIRROR" || (( WEBUI_FAILURES++ ))
done
else
warn "$MIRROR unreachable — WebUI reconfiguration skipped"
warn "$MIRROR will reconfigure its own WebUIs when it sees INACTIVE state on --check"
(( WEBUI_FAILURES++ ))
fi
# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━"
if [[ "$DRY_RUN" == false ]]; then
update_master_conf "CRITICAL_RSYNC_ENABLED" "false"
warn "CRITICAL_RSYNC_ENABLED=false ✅"
else
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
fi
# ── Step 5: Local container cleanup ───────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━"
cleanup_partner_containers
# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────
start_own_stack
# ── Step 7: Remote container cleanup ──────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_CONTAINERS Step 7/10 — Remote Container Cleanup ━━━"
if [[ "$MIRROR_REACHABLE" == true ]]; then
# Remove auth/arr stack containers deployed during onboard (by config array)
cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
# Remove fallback coverage containers (by *-owner_short naming pattern)
cleanup_owner_containers_on_mirror "$MIRROR_IP"
else
warn "$MIRROR unreachable — remote container cleanup skipped"
warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"
fi
# ── Step 8: Restart mirror's own stack ────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_START Step 8/10 — Restart Mirror Stack ━━━"
[[ "$MIRROR_REACHABLE" == true ]] && start_mirror_own_stack "$MIRROR_IP"
# ── Step 9: Revocation (Emby + SSH) ──────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Step 9/10 — Revocation ━━━"
# Emby admin — before SSH key revocation while Emby still reachable
[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"
# SSH key revocation — mutual, both directions; must run while Tailscale still active
do_ssh_key_revocation "${MIRROR_IP:-}"
# ── Step 10: Write state, push to mirror, blocklist ───────────────────────────────────────────
# State is written after container cleanup and SSH revocation so that:
# • Re-running after a crash between steps 59 restarts from scratch (no early-exit on INACTIVE)
# • --check sees INACTIVE during the Tailscale grace sleep and does not re-trigger offboard
echo ""
echo "━━━ $ICON_GEAR Step 10/10 — Write State ━━━"
NOW=$(date '+%Y-%m-%d %H:%M:%S')
if [[ "$DRY_RUN" == false ]]; then
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
echo "Local state: INACTIVE ✅"
add_to_blocklist "$MIRROR" "$REASON"
[[ "$MIRROR_REACHABLE" == true ]] && \
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
else
warn "DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote"
fi
# Tailscale removal — after state written so --check does not re-trigger offboard during grace sleep
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]]; then
echo ""
echo "━━━ $ICON_NET Tailscale Separation ━━━"
if [[ "$MIRROR_REACHABLE" == true ]]; then
grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))
warn "Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups..."
if [[ "$DRY_RUN" == false ]]; then
trap 'warn "Offboard interrupted during grace sleep"; exit 0' SIGTERM SIGINT
sleep "$grace_seconds"
trap - SIGTERM SIGINT
fi
fi
remove_tailscale_device "$MIRROR"
fi
# Backup handover notification
if [[ ${#PARTNERSHIP_MIRROR_BACKUPS[@]} -gt 0 ]]; then
echo ""
echo "━━━ $ICON_DISK Backup Handover ━━━"
echo "Backups available for $MIRROR:"
for path in "${PARTNERSHIP_MIRROR_BACKUPS[@]}"; do
[[ -z "$path" ]] && continue
echo " $path"
done
notify "$MIRROR offboard complete — backups available for ${PARTNERSHIP_GRACE_HOURS:-6}hr. Tailscale access expires then." \
"Partnership" "warning"
fi
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Owner) ━━━━━"
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Mirror: $MIRROR_ID ($MIRROR)"
echo " Reason: $REASON"
echo " Duration: $(format_duration $(( END - START )))"
echo ""
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
_revoke_status() {
if [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
echo "both directions ✅"
elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
echo "local only ✅ — remote failed (revoke manually on $MIRROR)"
else
echo "⚠️ failed — check warnings above"
fi
}
echo " Step 1 — Stop rsync: $(_ok "$STEP_STOP_OK")"
echo " Step 2 — Final sync: $(_ok "$STEP_SYNC_OK")"
echo " Step 3 — WebUI failures: $WEBUI_FAILURES"
echo " Step 4 — Disable sync: ✅"
echo " Step 5 — Local cleanup: ✅"
echo " Step 6 — Own stack: started"
echo " Step 7 — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )"
echo " Step 8 — Mirror stack: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "started" || echo "skipped (unreachable)" )"
echo " Step 9 — Keys revoked: $(_revoke_status)"
echo " Step 10 — State: INACTIVE ✅"
echo ""
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
echo " Tailscale: $MIRROR removed ✅"
echo ""
echo " $MIRROR leaves with:"
echo " ✓ Current auth config (final sync)"
echo " ✓ Auth WebUIs → localhost"
echo " ✓ ${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups"
echo ""
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
warn "$ICON_DONE DONE — clean separation complete ✅"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0