added manual neuclear option to arrs cleanup

This commit is contained in:
2026-04-26 01:05:22 -04:00
parent f826672b80
commit 3df4542351
11 changed files with 988 additions and 97 deletions
+16 -12
View File
@@ -2,21 +2,25 @@
# -----------------------------------------------------------------------------------------------
# --------------------------------- Array Start Orchestrator -----------------------------------
# -----------------------------------------------------------------------------------------------
# Launches all scripts configured in ARRAY_START_SCRIPTS when the unRAID array comes online.
# Set this script to run at "Startup of Array" in the User Scripts plugin.
# Single entry point for "At Startup of Array" in User Scripts plugin.
# Launches everything configured in ARRAY_START_SCRIPTS in Master.conf.
#
# Each script is launched as a background process:
# One-shot scripts (ramdisk_setup, syslog_filter etc.) run and exit naturally
# Continuous scripts (system_watchdog, docker_watchdog, failover) run until array stops
# What it launches (configured in Master.conf ARRAY_START_SCRIPTS):
# Transcodes/ramdisk_setup.sh — creates tmpfs + symlink before Emby starts (one-shot)
# unRAID_Essentials/docker_syslog_filter.sh — suppress veth log noise (one-shot)
# unRAID_Essentials/php_fpm_max_children.sh — WebGUI performance tuning (one-shot)
# Docker_Essentials/docker_network_connect.sh — connect containers to extra networks (one-shot)
# unRAID_Essentials/system_watchdog.sh — system health monitor (continuous loop)
# Docker_Essentials/docker_watchdog.sh — container health monitor (continuous loop)
# Failover/failover.sh — mutual failover monitor (continuous loop)
#
# Scripts are launched in the order defined in ARRAY_START_SCRIPTS in Master.conf.
# Order matters — ramdisk before Emby, network before watchdogs, watchdogs before failover.
#
# To add or remove a script: edit ARRAY_START_SCRIPTS in Master.conf.
# No changes to this script needed.
#
# Logs: each script logs its own output independently.
# One-shot scripts run and exit naturally — array_start.sh confirms completion.
# Continuous scripts run until array stops or SIGTERM received.
# This orchestrator exits after launching all scripts — unRAID sees it complete normally.
#
# Add or remove scripts: edit ARRAY_START_SCRIPTS in Master.conf.
# Order matters — ramdisk first, network before watchdogs, watchdogs before failover.
# No changes to this script ever needed.
# -----------------------------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+19 -13
View File
@@ -3,23 +3,29 @@
# --------------------------------- Daily Sync Maintenance ------------------------------------
# -----------------------------------------------------------------------------------------------
# Daily orchestrator — runs the full daily maintenance window in the correct order.
# Schedule: 0 1 * * * (1am daily)
#
# What it does:
# 1. Iterates DAILY_MAINTENANCE_SCRIPTS — runs git pull first, then additional jobs
# 2. Syncs all media shares in the correct direction for the local server
# 3. Additional scripts in DAILY_MAINTENANCE_SCRIPTS run after the sync completes
# Execution order:
# 1. git_pull_execute.sh — pull latest scripts first, always
# 2. Media share sync — HOST*_DAILY_SYNC_SHARES pushed to remote
# 3. HOST*_PERSONAL_SHARES — personal encrypted shares after media
# 4. media_shares_permissions.sh — fix ownership before arr cleanup
# 5. media_cleaner.sh anime — remove junk from anime shares
# 6. media_cleaner.sh media — remove junk from media shares
# 7. lidarr_cleanup.sh — remove orphaned music files
# 8. sonarr_cleanup.sh — remove orphaned TV files
# 9. radarr_cleanup.sh — remove orphaned movie files
# 10. docker_daily_restart.sh — restart containers that need daily restart
#
# Media share sync:
# Each server pushes only the shares it owns (source of truth) — direction is automatic.
# HOST1 pushes: Movies, Tv_Shows, Music, Books etc. → HOST2
# HOST2 pushes: Anime_Shows, Anime_Movies → HOST1
# Personal encrypted shares synced after media shares.
# detect_hosts() determines which server is running — no script changes needed.
# Share lists configured in Master.conf ORCHESTRATORS section.
# Configuration in Master.conf:
# DAILY_MAINTENANCE_SCRIPTS — pre/post-sync scripts (git pull, docker restart)
# MEDIA_MANAGEMENT_JOBS — media maintenance jobs run after sync
# HOST1_DAILY_SYNC_SHARES — shares HOST1 pushes to HOST2
# HOST2_DAILY_SYNC_SHARES — shares HOST2 pushes to HOST1
# HOST1/2_PERSONAL_SHARES — encrypted personal shares
#
# Bidirectional — same script runs on both servers, correct direction automatic.
# Per-share rsync handled by rsync.sh — this script tracks pass/fail and total time.
# All job lists configured in Master.conf — no script changes needed to add or remove jobs.
# Schedule: 0 1 * * * (1am daily — configured in User Scripts plugin)
# -----------------------------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -0,0 +1,633 @@
#!/bin/bash
# -----------------------------------------------------------------------------------------------
# ----------------------------- Sunday Morning Coffee Report -----------------------------------
# -----------------------------------------------------------------------------------------------
# Weekly system overview — everything that happened this week in one clean read.
# Designed to be read over coffee Sunday morning while the system is fully caught up
# from the 2:30am maintenance window.
# Schedule: 0 7 * * 0 (7am Sunday — after weekly_sync_maintenance.sh finishes at ~3am)
# Maintenance window completes → 4 hours of fresh data → report ready ☕
#
# Sections:
# 🖥️ System — uptime, memory, boot drive, cache drive, reboots
# 📀 Array — disk count, parity status, ZFS health, drive temps
# 🔀 Failover — state, last change, Tailscale connectivity
# 🎬 Transcodes — ramdisk usage, weekly peak, flips, session split
# 🎵 Media Activity — arr cleanup stats, arr recovery stats, library health
# 🌐 Rsync — weekly transfer totals, per-share breakdown
# 🛡️ Watchdog — container strikes, restarts, system strikes, skip list
# 🔐 Security — SSL cert expiry per domain
# 📊 Emby — weekly stream count, top users, top content
# ⚙️ Health — SMART summary, docker container count, Gitea sync status
# ⚠️ Issues — anything requiring attention collected above
#
# Data sources (reads only — no writes except the notification):
# DATA_DIR stats files — arr cleanup, recovery, transcode, bandwidth history
# /boot/config — failover state, watchdog reboot log
# /tmp — watchdog strike state files
# /proc, /sys — system memory, uptime
# /var/local/emhttp/ — unRAID array info
# Emby API — session history
# Arr APIs — current queue depth
# tailscale — network status
# openssl — live SSL cert check
# smartctl — drive health
#
# All configuration in Master.conf.
# Supports --dry-run to preview report without sending notification.
# -----------------------------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../Master.conf"
source "$SCRIPT_DIR/../common.sh"
parse_args "$@"
# -----------------------------------------------------------------------------------------------
# ━━━ Setup ━━━
# -----------------------------------------------------------------------------------------------
acquire_lock
detect_hosts
REPORT_DATE=$(date '+%A, %B %-d, %Y')
WEEK_START=$(date -d "7 days ago" '+%Y-%m-%d')
TODAY=$(date '+%Y-%m-%d')
NOW=$(date +%s)
REPORT=() # all report lines
ISSUES=() # items needing attention
FINDINGS=() # notable but not critical
section() {
REPORT+=("")
REPORT+=("$1")
REPORT+=("$(printf '%.0s─' {1..50})")
}
line() {
REPORT+=(" $1")
}
issue() {
ISSUES+=("$1")
REPORT+=(" ⚠️ $1")
}
finding() {
FINDINGS+=("$1")
REPORT+=(" $1")
}
format_bytes() {
local bytes=$1
if (( bytes > 1073741824 )); then
awk "BEGIN {printf \"%.1fGB\", $bytes / 1073741824}"
elif (( bytes > 1048576 )); then
awk "BEGIN {printf \"%.1fMB\", $bytes / 1048576}"
else
echo "${bytes}B"
fi
}
# -----------------------------------------------------------------------------------------------
# ━━━ 🖥️ System ━━━
# -----------------------------------------------------------------------------------------------
section "🖥️ SYSTEM"
# Uptime
UPTIME_SECONDS=$(awk '{print int($1)}' /proc/uptime)
UPTIME_DAYS=$(( UPTIME_SECONDS / 86400 ))
UPTIME_HOURS=$(( (UPTIME_SECONDS % 86400) / 3600 ))
BOOT_TIME=$(date -d "@$(( NOW - UPTIME_SECONDS ))" '+%A %-d %b at %-I:%M%p')
line "Uptime: ${UPTIME_DAYS}d ${UPTIME_HOURS}hr (up since $BOOT_TIME)"
# Reboot history this week
REBOOT_COUNT=0
if [[ -f "$SYS_WATCHDOG_REBOOT_LOG" ]]; then
WEEK_EPOCH=$(date -d "$WEEK_START" +%s)
REBOOT_COUNT=$(awk -v cutoff="$WEEK_EPOCH" '$1 >= cutoff' \
"$SYS_WATCHDOG_REBOOT_LOG" 2>/dev/null | wc -l)
fi
if [[ "$REBOOT_COUNT" -gt 0 ]]; then
issue "Reboots this week: $REBOOT_COUNT (system watchdog triggered)"
else
line "Reboots this week: 0 ✅"
fi
# Memory
MEM_TOTAL_KB=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
MEM_AVAIL_KB=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
MEM_USED_KB=$(( MEM_TOTAL_KB - MEM_AVAIL_KB ))
MEM_TOTAL_GB=$(awk "BEGIN {printf \"%.0f\", $MEM_TOTAL_KB / 1048576}")
MEM_USED_GB=$(awk "BEGIN {printf \"%.1f\", $MEM_USED_KB / 1048576}")
MEM_FREE_GB=$(awk "BEGIN {printf \"%.1f\", $MEM_AVAIL_KB / 1048576}")
ARC_SIZE=0
if [[ -f /proc/spl/kstat/zfs/arcstats ]]; then
ARC_BYTES=$(awk '/^size / {print $3}' /proc/spl/kstat/zfs/arcstats 2>/dev/null || echo 0)
ARC_SIZE=$(awk "BEGIN {printf \"%.1f\", $ARC_BYTES / 1073741824}")
fi
line "Memory: ${MEM_USED_GB}GB used / ${MEM_TOTAL_GB}GB total (ARC: ${ARC_SIZE}GB)"
# Boot drive
BOOT_PCT=$(df /boot --output=pcent 2>/dev/null | tail -1 | tr -d ' %')
BOOT_USED=$(df /boot -h --output=used 2>/dev/null | tail -1 | tr -d ' ')
BOOT_SIZE=$(df /boot -h --output=size 2>/dev/null | tail -1 | tr -d ' ')
if [[ "${BOOT_PCT:-0}" -ge 80 ]]; then
issue "Boot drive: ${BOOT_PCT}% used (${BOOT_USED}/${BOOT_SIZE}) — getting full"
else
line "Boot drive: ${BOOT_PCT}% used (${BOOT_USED}/${BOOT_SIZE})"
fi
# Cache drive
CACHE_PCT=$(df /mnt/cache --output=pcent 2>/dev/null | tail -1 | tr -d ' %')
CACHE_AVAIL=$(df /mnt/cache -h --output=avail 2>/dev/null | tail -1 | tr -d ' ')
CACHE_SIZE=$(df /mnt/cache -h --output=size 2>/dev/null | tail -1 | tr -d ' ')
if [[ "${CACHE_PCT:-0}" -ge 85 ]]; then
issue "Cache drive: ${CACHE_PCT}% used (${CACHE_AVAIL} free of ${CACHE_SIZE})"
else
line "Cache drive: ${CACHE_PCT}% used (${CACHE_AVAIL} free of ${CACHE_SIZE})"
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 📀 Array ━━━
# -----------------------------------------------------------------------------------------------
section "📀 ARRAY"
# unRAID array info
if [[ -f /var/local/emhttp/disks.ini ]]; then
DISK_COUNT=$(grep -c "^\[disk" /var/local/emhttp/disks.ini 2>/dev/null || echo "?")
line "Array disks: $DISK_COUNT"
fi
# Parity
if [[ -f /var/local/emhttp/parity-date.txt ]]; then
PARITY_INFO=$(cat /var/local/emhttp/parity-date.txt 2>/dev/null)
if echo "$PARITY_INFO" | grep -q "progress"; then
finding "Parity check in progress"
else
PARITY_DATE=$(echo "$PARITY_INFO" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)
PARITY_ERRORS=$(echo "$PARITY_INFO" | grep -oE 'errors=[0-9]+' | cut -d= -f2 || echo 0)
if [[ "${PARITY_ERRORS:-0}" -gt 0 ]]; then
issue "Parity: $PARITY_ERRORS errors on last check ($PARITY_DATE)"
else
line "Parity: OK (last check: ${PARITY_DATE:-unknown}, 0 errors)"
fi
fi
fi
# ZFS pool health
if command -v zpool >/dev/null 2>&1; then
POOLS=$(zpool list -H -o name,health 2>/dev/null)
while IFS=$'\t' read -r pool health; do
[[ -z "$pool" ]] && continue
# Skip single-disk unRAID array pools
if [[ "$health" == "ONLINE" ]]; then
line "ZFS $pool: ONLINE ✅"
else
issue "ZFS $pool: $health — check immediately"
fi
done <<< "$POOLS"
fi
# Drive temperatures from SMART
if command -v smartctl >/dev/null 2>&1; then
TEMP_WARN=false
TEMP_SUMMARY=""
for disk in /dev/sd? /dev/nvme?; do
[[ ! -e "$disk" ]] && continue
DISK_NAME=$(basename "$disk")
# Skip ignored drives
SKIP=false
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
[[ "$DISK_NAME" == "$ignore" ]] && SKIP=true && break
done
[[ "$SKIP" == true ]] && continue
TEMP=$(smartctl -A "$disk" 2>/dev/null | \
awk '/Temperature_Celsius|Temperature:/ {print $NF; exit}' | tr -d '°C')
[[ -z "$TEMP" ]] && continue
TEMP_INT=$(printf "%.0f" "$TEMP" 2>/dev/null || echo 0)
if [[ "$TEMP_INT" -ge "${SMART_TEMP_CRIT:-55}" ]]; then
issue "Drive $DISK_NAME: ${TEMP_INT}°C — CRITICAL"
TEMP_WARN=true
elif [[ "$TEMP_INT" -ge "${SMART_TEMP_WARN:-45}" ]]; then
finding "Drive $DISK_NAME: ${TEMP_INT}°C — warm"
TEMP_WARN=true
fi
[[ -n "$TEMP_SUMMARY" ]] && TEMP_SUMMARY+=", "
TEMP_SUMMARY+="${DISK_NAME}:${TEMP_INT}°C"
done
if [[ "$TEMP_WARN" == false ]]; then
line "Drive temps: all normal"
fi
[[ -n "$TEMP_SUMMARY" ]] && line "Temps: $TEMP_SUMMARY"
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 🔀 Failover ━━━
# -----------------------------------------------------------------------------------------------
section "🔀 FAILOVER"
if [[ -f "$FAILOVER_STATE_FILE" ]]; then
FAILOVER_STATE=$(grep "^state=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2)
FAILOVER_CHANGE=$(grep "^last_change=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2)
if [[ "$FAILOVER_STATE" == "NORMAL" ]]; then
line "State: NORMAL ✅"
else
issue "Failover state: $FAILOVER_STATE — not normal"
fi
line "Last change: ${FAILOVER_CHANGE:-unknown}"
else
issue "Failover state file not found"
fi
# Tailscale connectivity
if command -v tailscale >/dev/null 2>&1; then
TS_STATUS=$(tailscale status 2>/dev/null)
REMOTE_VISIBLE=$(echo "$TS_STATUS" | grep -c "$HOST2" 2>/dev/null || echo 0)
if [[ "$REMOTE_VISIBLE" -gt 0 ]]; then
REMOTE_IP=$(tailscale ip -4 "$HOST2" 2>/dev/null || echo "unknown")
line "Tailscale: $HOST2 visible at $REMOTE_IP"
else
issue "Tailscale: $HOST2 not visible — check connectivity"
fi
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 🎬 Transcodes ━━━
# -----------------------------------------------------------------------------------------------
section "🎬 TRANSCODES"
if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then
RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used | tail -1 | tr -d ' ')
RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", $RAMDISK_USED_KB / 1048576}")
RAMDISK_AVAIL_KB=$(df "$RAMDISK_PATH" --output=avail | tail -1 | tr -d ' ')
RAMDISK_AVAIL_GB=$(awk "BEGIN {printf \"%.2f\", $RAMDISK_AVAIL_KB / 1048576}")
SYMLINK_TARGET=$(readlink "$TRANSCODE_LINK" 2>/dev/null | xargs basename 2>/dev/null || echo "unknown")
line "Ramdisk now: ${RAMDISK_USED_GB}GB used / ${RAMDISK_AVAIL_GB}GB free → $SYMLINK_TARGET"
else
issue "Ramdisk not mounted"
fi
if [[ -f "$TRANSCODE_DAILY_LOG" ]]; then
WEEK_PEAK=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {if ($2 > max) max=$2} END {printf "%.2f", max+0}' \
"$TRANSCODE_DAILY_LOG")
WEEK_FLIPS=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
WEEK_RAM=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$4} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
WEEK_SSD=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$5} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
WEEK_FILES=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$6} END {print sum+0}' "$TRANSCODE_DAILY_LOG")
line "Week peak: ${WEEK_PEAK}GB | flips: ${WEEK_FLIPS} | files cleaned: ${WEEK_FILES}"
line "Sessions: ${WEEK_RAM} ramdisk / ${WEEK_SSD} SSD"
PEAK_INT=$(printf "%.0f" "$WEEK_PEAK" 2>/dev/null || echo 0)
WARN_INT=$(printf "%.0f" "$RAMDISK_WARN_GB" 2>/dev/null || echo 0)
if [[ "$PEAK_INT" -ge "$WARN_INT" ]]; then
finding "Transcode peak ${WEEK_PEAK}GB reached warn threshold — consider increasing RAMDISK_SIZE"
fi
if [[ "${WEEK_FLIPS:-0}" -ge "${TRANSCODE_FLIP_WARN:-3}" ]]; then
finding "Transcode flips this week: $WEEK_FLIPS — monitor ramdisk headroom"
fi
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 🎵 Media Activity ━━━
# -----------------------------------------------------------------------------------------------
section "🎵 MEDIA ACTIVITY"
# Arr cleanup stats
if [[ -f "$ARR_CLEANUP_STATS" ]]; then
for arr in lidarr sonarr radarr; do
WEEK_ORPHANS=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
'$1 >= cutoff && $2 == a {sum+=$3} END {print sum+0}' "$ARR_CLEANUP_STATS")
WEEK_BYTES=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
'$1 >= cutoff && $2 == a {sum+=$4} END {print sum+0}' "$ARR_CLEANUP_STATS")
WEEK_TRACKED=$(awk -F'|' -v cutoff="$WEEK_START" -v a="$arr" \
'BEGIN{max=0} $1 >= cutoff && $2 == a && $8 > max {max=$8} END {print max+0}' \
"$ARR_CLEANUP_STATS")
if [[ "${WEEK_ORPHANS:-0}" -gt 0 ]]; then
FREED=$(format_bytes "${WEEK_BYTES:-0}")
line "${arr^} cleanup: $WEEK_ORPHANS orphans removed ($FREED freed) | tracked: $WEEK_TRACKED files"
else
line "${arr^} cleanup: clean ✅ (tracked: $WEEK_TRACKED files)"
fi
done
else
line "Arr cleanup stats: no data yet (runs after first cleanup)"
fi
# Arr recovery stats
if [[ -f "$ARR_RECOVERY_STATS" ]]; then
WEEK_ACTIONED=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$ARR_RECOVERY_STATS")
WEEK_RUNS=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {count++} END {print count+0}' "$ARR_RECOVERY_STATS")
if [[ "${WEEK_ACTIONED:-0}" -gt 0 ]]; then
line "Arr recovery: $WEEK_ACTIONED items auto-recovered across $WEEK_RUNS runs"
else
line "Arr recovery: no failed imports this week ✅"
fi
fi
# Arr queue depth — current snapshot
for arr_name in "Sonarr|${HOST1_SONARR_URL}|${HOST1_SONARR_API_KEY}|v3" \
"Radarr|${HOST1_RADARR_URL}|${HOST1_RADARR_API_KEY}|v3" \
"Lidarr|${HOST1_LIDARR_URL}|${HOST1_LIDARR_API_KEY}|v1"; do
IFS='|' read -r name url key ver <<< "$arr_name"
if [[ "$url" == *"your-"* ]] || [[ -z "$key" ]]; then continue; fi
QUEUE=$(curl -sf --max-time 5 -H "X-Api-Key: $key" \
"${url}/api/${ver}/queue?pageSize=1" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totalRecords',0))" \
2>/dev/null || echo "?")
if [[ "$QUEUE" == "0" ]] || [[ -z "$QUEUE" ]]; then
line "$name queue: empty ✅"
else
line "$name queue: $QUEUE items"
fi
done
# -----------------------------------------------------------------------------------------------
# ━━━ 🌐 Rsync ━━━
# -----------------------------------------------------------------------------------------------
section "🌐 RSYNC"
if [[ -f "$BANDWIDTH_LOG" ]] && [[ -s "$BANDWIDTH_LOG" ]]; then
WEEK_TOTAL_BYTES=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {sum+=$3} END {print sum+0}' "$BANDWIDTH_LOG")
WEEK_SYNCS=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff' "$BANDWIDTH_LOG" | wc -l)
WEEK_FAILED=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff && $5 != "success"' "$BANDWIDTH_LOG" | wc -l)
WEEK_GB=$(awk "BEGIN {printf \"%.1f\", $WEEK_TOTAL_BYTES / 1073741824}")
line "Total transferred: ${WEEK_GB}GB across $WEEK_SYNCS syncs"
if [[ "${WEEK_FAILED:-0}" -gt 0 ]]; then
issue "Failed syncs this week: $WEEK_FAILED"
else
line "Sync failures: none ✅"
fi
# Top 3 shares by transfer this week
TOP_SHARES=$(awk -F'|' -v cutoff="$WEEK_START" \
'$1 >= cutoff {bytes[$4]+=$3} END {for(s in bytes) print bytes[s], s}' \
"$BANDWIDTH_LOG" | sort -rn | head -3)
if [[ -n "$TOP_SHARES" ]]; then
while IFS=' ' read -r bytes share; do
[[ -z "$share" ]] && continue
SIZE=$(format_bytes "$bytes")
line "$share: $SIZE"
done <<< "$TOP_SHARES"
fi
else
line "No bandwidth data yet"
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 🛡️ Watchdog ━━━
# -----------------------------------------------------------------------------------------------
section "🛡️ WATCHDOG"
# Container watchdog strikes
if [[ -f "$WATCHDOG_STATE_FILE" ]]; then
ACTIVE_STRIKES=$(grep -v ":0$" "$WATCHDOG_STATE_FILE" 2>/dev/null | grep -v "^$" | wc -l)
if [[ "$ACTIVE_STRIKES" -gt 0 ]]; then
STRIKE_LIST=$(grep -v ":0$" "$WATCHDOG_STATE_FILE" 2>/dev/null | tr '\n' ' ')
issue "Container watchdog: $ACTIVE_STRIKES active strikes — $STRIKE_LIST"
else
line "Container watchdog: no active strikes ✅"
fi
fi
# System watchdog strikes
if [[ -f "$SYS_WATCHDOG_STATE_FILE" ]]; then
SYS_STRIKES=$(grep -v ":0$" "$SYS_WATCHDOG_STATE_FILE" 2>/dev/null | grep -v "^$" | wc -l)
if [[ "$SYS_STRIKES" -gt 0 ]]; then
SYS_LIST=$(grep -v ":0$" "$SYS_WATCHDOG_STATE_FILE" 2>/dev/null | tr '\n' ' ')
issue "System watchdog: $SYS_STRIKES active strikes — $SYS_LIST"
else
line "System watchdog: no active strikes ✅"
fi
fi
# Container skip list
if [[ -f "$SYS_WATCHDOG_FAILED_FILE" ]] && [[ -s "$SYS_WATCHDOG_FAILED_FILE" ]]; then
SKIP_COUNT=$(wc -l < "$SYS_WATCHDOG_FAILED_FILE")
SKIP_LIST=$(cat "$SYS_WATCHDOG_FAILED_FILE" | tr '\n' ' ')
issue "Skip list: $SKIP_COUNT containers — $SKIP_LIST — manual intervention needed"
else
line "Skip list: empty ✅"
fi
# Container restarts this week
if [[ -f "$WATCHDOG_CONTAINER_RESTART_LOG" ]]; then
WEEK_RESTARTS=$(awk -F'|' -v cutoff="$WEEK_START" \
'$2 >= cutoff' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | wc -l)
if [[ "${WEEK_RESTARTS:-0}" -gt 0 ]]; then
RESTARTED=$(awk -F'|' -v cutoff="$WEEK_START" \
'$2 >= cutoff {print $1}' "$WATCHDOG_CONTAINER_RESTART_LOG" 2>/dev/null | \
sort | uniq -c | sort -rn | head -5 | \
awk '{print $2 "(" $1 ")"}' | tr '\n' ' ')
finding "Watchdog restarted $WEEK_RESTARTS containers this week: $RESTARTED"
else
line "Watchdog restarts: none this week ✅"
fi
fi
# Docker container count
if command -v docker >/dev/null 2>&1; then
RUNNING=$(docker ps -q 2>/dev/null | wc -l)
TOTAL=$(docker ps -aq 2>/dev/null | wc -l)
STOPPED=$(( TOTAL - RUNNING ))
if [[ "$STOPPED" -gt 0 ]]; then
STOPPED_NAMES=$(docker ps -af "status=exited" --format "{{.Names}}" 2>/dev/null | \
head -5 | tr '\n' ' ')
finding "Docker: $RUNNING/$TOTAL running — $STOPPED stopped: $STOPPED_NAMES"
else
line "Docker: $RUNNING/$TOTAL containers running ✅"
fi
fi
# -----------------------------------------------------------------------------------------------
# ━━━ 🔐 Security ━━━
# -----------------------------------------------------------------------------------------------
section "🔐 SECURITY"
for domain in "${CERT_MONITOR_DOMAINS[@]:-}"; do
[[ -z "$domain" ]] && continue
EXPIRY=$(echo | timeout "${CERT_TIMEOUT:-10}" openssl s_client \
-connect "${domain}:443" -servername "$domain" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -z "$EXPIRY" ]]; then
issue "$domain: could not check certificate"
continue
fi
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || echo 0)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW) / 86400 ))
if [[ "$DAYS_LEFT" -le "${CERT_CRIT_DAYS:-7}" ]]; then
issue "$domain: ${DAYS_LEFT} days remaining — CRITICAL, renew now"
elif [[ "$DAYS_LEFT" -le "${CERT_WARN_DAYS:-30}" ]]; then
finding "$domain: ${DAYS_LEFT} days remaining — renew soon"
else
line "$domain: ${DAYS_LEFT} days remaining ✅"
fi
done
# -----------------------------------------------------------------------------------------------
# ━━━ 📊 Emby ━━━
# -----------------------------------------------------------------------------------------------
section "📊 EMBY"
# Select correct Emby URL and key for this host
if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then
EMBY_URL="$HOST1_EMBY_URL"
EMBY_KEY="$HOST1_EMBY_API_KEY"
else
EMBY_URL="$HOST2_EMBY_URL"
EMBY_KEY="$HOST2_EMBY_API_KEY"
fi
if [[ "$EMBY_KEY" != *"your-"* ]] && [[ -n "$EMBY_KEY" ]]; then
WEEK_MS=$(( 7 * 24 * 3600 * 10000000 ))
ACTIVITY=$(curl -sf --max-time 10 \
-H "X-Emby-Token: $EMBY_KEY" \
"${EMBY_URL}/Sessions?ControllableByUserId=&api_key=$EMBY_KEY" \
2>/dev/null)
# Use activity log for weekly stats
ACTIVITY_LOG=$(curl -sf --max-time 10 \
-H "X-Emby-Token: $EMBY_KEY" \
"${EMBY_URL}/user_usage_stats/user_activity?days=7&api_key=$EMBY_KEY" \
2>/dev/null)
if [[ -n "$ACTIVITY_LOG" ]]; then
TOTAL_PLAYS=$(echo "$ACTIVITY_LOG" | \
python3 -c "import sys,json; d=json.load(sys.stdin); \
print(sum(u.get('total_plays',0) for u in d))" 2>/dev/null || echo "?")
line "Streams this week: $TOTAL_PLAYS total plays"
# Top 3 users
TOP_USERS=$(echo "$ACTIVITY_LOG" | \
python3 -c "
import sys,json
d=json.load(sys.stdin)
users=sorted(d,key=lambda x:x.get('total_plays',0),reverse=True)[:3]
for u in users:
print(f\" → {u.get('user_name','?')}: {u.get('total_plays',0)} plays\")
" 2>/dev/null)
[[ -n "$TOP_USERS" ]] && echo "$TOP_USERS" | while IFS= read -r l; do line "$l"; done
else
# Fallback — just show active sessions
ACTIVE=$(curl -sf --max-time 5 \
-H "X-Emby-Token: $EMBY_KEY" \
"${EMBY_URL}/Sessions?api_key=$EMBY_KEY" 2>/dev/null | \
python3 -c "import sys,json; \
d=json.load(sys.stdin); \
active=[s for s in d if s.get('NowPlayingItem')]; \
print(f'{len(active)} active streams now')" 2>/dev/null || echo "API unavailable")
line "Emby: $ACTIVE"
fi
else
line "Emby: API key not configured"
fi
# -----------------------------------------------------------------------------------------------
# ━━━ ⚙️ System Health ━━━
# -----------------------------------------------------------------------------------------------
section "⚙️ SYSTEM HEALTH"
# SMART summary
if command -v smartctl >/dev/null 2>&1; then
SMART_ISSUES=0
for disk in /dev/sd? /dev/nvme?; do
[[ ! -e "$disk" ]] && continue
DISK_NAME=$(basename "$disk")
SKIP=false
for ignore in "${SMART_IGNORE_DRIVES[@]:-}"; do
[[ "$DISK_NAME" == "$ignore" ]] && SKIP=true && break
done
[[ "$SKIP" == true ]] && continue
HEALTH=$(smartctl -H "$disk" 2>/dev/null | grep "SMART overall-health" | \
awk '{print $NF}')
if [[ "$HEALTH" != "PASSED" ]] && [[ -n "$HEALTH" ]]; then
issue "SMART $DISK_NAME: $HEALTH — check immediately"
((SMART_ISSUES++))
fi
done
if [[ "$SMART_ISSUES" -eq 0 ]]; then
line "SMART: all drives PASSED ✅"
fi
fi
# Gitea sync status
if command -v git >/dev/null 2>&1 && [[ -d "$TARGET_DIR/.git" ]]; then
CURRENT_COMMIT=$(git -C "$TARGET_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
LAST_PULL=$(git -C "$TARGET_DIR" log -1 --format="%ar" 2>/dev/null || echo "unknown")
line "Gitea: commit $CURRENT_COMMIT (pulled $LAST_PULL)"
fi
# -----------------------------------------------------------------------------------------------
# ━━━ ⚠️ Issues Requiring Attention ━━━
# -----------------------------------------------------------------------------------------------
if [[ ${#ISSUES[@]} -gt 0 ]]; then
REPORT+=("")
REPORT+=("⚠️ ISSUES REQUIRING ATTENTION")
REPORT+=("$(printf '%.0s─' {1..50})")
for issue_line in "${ISSUES[@]}"; do
REPORT+=("$issue_line")
done
fi
if [[ ${#FINDINGS[@]} -gt 0 ]]; then
REPORT+=("")
REPORT+=("️ NOTABLE")
REPORT+=("$(printf '%.0s─' {1..50})")
for finding_line in "${FINDINGS[@]}"; do
REPORT+=("$finding_line")
done
fi
# -----------------------------------------------------------------------------------------------
# ━━━ Footer ━━━
# -----------------------------------------------------------------------------------------------
REPORT+=("")
if [[ ${#ISSUES[@]} -eq 0 ]]; then
STATUS="✅ All systems healthy — enjoy your Sunday"
else
STATUS="⚠️ ${#ISSUES[@]} issue(s) need attention"
fi
REPORT+=("$STATUS")
REPORT+=("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
# -----------------------------------------------------------------------------------------------
# ━━━ Output and Send ━━━
# -----------------------------------------------------------------------------------------------
HEADER="☕ SUNDAY MORNING COFFEE REPORT — $REPORT_DATE"
DIVIDER="$(printf '%.0s━' {1..50})"
echo ""
echo "$DIVIDER"
echo "$HEADER"
echo "$DIVIDER"
for report_line in "${REPORT[@]}"; do
echo "$report_line"
done
echo ""
# Send notification
BODY=$(printf '%s\n' "$HEADER" "${REPORT[@]}")
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — notification not sent"
else
notify "$BODY" "☕ Weekly Report" "normal"
success "Report sent"
fi
+34 -27
View File
@@ -1,38 +1,45 @@
#!/bin/bash
# -----------------------------------------------------------------------------------------------
# ----------------------------- Weekly Sync Maintenance ------------------------------------
# ----------------------------- Weekly Sync Maintenance ----------------------------------------
# -----------------------------------------------------------------------------------------------
# Maintenance window orchestrator for Emby and the auth stack (Critical-Data).
# Both local and remote containers are stopped for the entire window — clean state
# guaranteed for sync, updates, and restart.
# Weekly maintenance window orchestrator — clean sync, container updates, weekly restarts.
# Schedule: 30 2 * * 0 (Sunday 2:30am — fits before 3am network reboot)
#
# What it does (in order):
# 1. Stop local containers auth stack + Emby stopped locally
# 2. Stop remote containers auth stack + Emby stopped remotely via SSH
# 3. Pull updates locally — if CRITICAL_SYNC_UPDATES=true
# 4. Pull updates remotely — if CRITICAL_SYNC_UPDATES_REMOTE=true
# 5. rsync Emby — clean full sync, both sides down
# 6. rsync Critical-Data — clean full sync, databases fully flushed
# 7. Start remote containers — starts on new images, correct order
# 8. Start local containers — starts on new images, correct order
# 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 CRITICAL_SYNC_UPDATES=true
# 4. Pull updates remotely — if CRITICAL_SYNC_UPDATES_REMOTE=true
# 5. rsync Emby — full clean mirror, both instances stopped
# 6. rsync Critical-Data — auth stack clean sync, databases flushed
# 7. Start remote containers — correct order, delayed start respected
# 8. Start local containers — correct order, delayed start respected
# 9. docker_weekly_restart.sh — weekly container restarts
#
# Why this is better than separate update + sync jobs:
# Containers are already stopped for the sync — no extra downtime for updates
# Both sides on identical image versions after restart
# Databases synced before first start on new version — clean state guaranteed
# One maintenance window handles sync + updates + ordered restart
# Synced shares (WEEKLY_SYNC_JOBS in Master.conf):
# /mnt/user/Media_Server/Emby — emby profile — full mirror, cache resets weekly
# /mnt/user/appdata-Failover/Critical-Data — critical-data — auth stack clean state
#
# Toggle updates on/off in Master.conf:
# CRITICAL_SYNC_UPDATES=true/false — local updates
# CRITICAL_SYNC_UPDATES_REMOTE=true/false — remote updates
# Both false = sync only (sync only, no updates)
# Why weekly instead of 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-failover dirty sync covers watch states + library every 30-60min between syncs
#
# Container lists and startup order from PROFILE_CRITICAL_CONTAINER_NAMES
# Delayed containers (Authelia, Authelia-Secondary) respected on restart
# Containers not found on a server skipped gracefully
# Only containers that were running get restarted — stopped containers stay stopped
# Container updates during the window:
# Containers already stopped for sync — updates pull at zero extra downtime
# Both servers start on identical image versions after the window completes
# Toggle: CRITICAL_SYNC_UPDATES / CRITICAL_SYNC_UPDATES_REMOTE in Master.conf
#
# Recommended schedule: 30 2 * * 0 (Sunday 2:30am)
# What triggers weekly_health_digest.sh:
# NOT this script — weekly_health_digest.sh runs on its own Saturday schedule
#
# Configuration in Master.conf:
# WEEKLY_SYNC_JOBS — shares synced during the maintenance window
# WEEKLY_MAINTENANCE_SCRIPTS — scripts run after sync (docker_weekly_restart)
# CRITICAL_SYNC_UPDATES — toggle container updates on/off
# CRITICAL_SYNC_UPDATES_REMOTE — toggle remote container updates on/off
# -----------------------------------------------------------------------------------------------
# All configuration in Master.conf.
# Supports --dry-run to walk through without stopping containers, syncing, or updating.
# -----------------------------------------------------------------------------------------------