From 2ae93634586fd437000dec31f6bb03ae50a9563f Mon Sep 17 00:00:00 2001 From: Rufus King Date: Sun, 26 Jul 2026 21:35:57 -0400 Subject: [PATCH] Initial import --- KingDezigns-Summary-2026-06-21.html | 498 +++++++++++++++++++++++ PLEX32-Launcher.desktop | 9 + PLEX32-Optimization-Summary.html | 608 ++++++++++++++++++++++++++++ plex32_backup.sh | 471 +++++++++++++++++++++ plex32_backup2.sh | 365 +++++++++++++++++ plex32_health_report.sh | 598 +++++++++++++++++++++++++++ plex32_launcher.sh | 88 ++++ plex32_reboot.sh | 115 ++++++ plex32_update_check.sh | 383 ++++++++++++++++++ 9 files changed, 3135 insertions(+) create mode 100644 KingDezigns-Summary-2026-06-21.html create mode 100644 PLEX32-Launcher.desktop create mode 100644 PLEX32-Optimization-Summary.html create mode 100644 plex32_backup.sh create mode 100644 plex32_backup2.sh create mode 100644 plex32_health_report.sh create mode 100644 plex32_launcher.sh create mode 100644 plex32_reboot.sh create mode 100644 plex32_update_check.sh diff --git a/KingDezigns-Summary-2026-06-21.html b/KingDezigns-Summary-2026-06-21.html new file mode 100644 index 0000000..292ac92 --- /dev/null +++ b/KingDezigns-Summary-2026-06-21.html @@ -0,0 +1,498 @@ + + + + + +KingDezigns — PLEX32 Timezone Fix & Remote Reboot Script Summary + + + + + +
+ +
+

KingDezigns — PLEX32 Timezone Fix & Remote Reboot Script summary

+
+ 📅 2026-06-21 + 🖥 PLEX32 / NAS08 + ⚙️ Debian / OpenMediaVault + 🐳 Docker Compose + ☁️ Plex Media Server +
+
+ + + +
+
The problem
+ +

The PLEX32 backup script ran at 10:00 PM instead of the scheduled 2:00 AM, indicating a scheduling problem. A separate gap was also identified: there was no way to remotely reboot PLEX32 when away from home if it became unresponsive.

+ +
+ Backup ran at 10 PM instead of 2 AM + No remote reboot capability for PLEX32 +
+ +

The crontab entry on PLEX32 was correct (0 2 */3 * *), but the system timezone was set to Etc/UTC instead of America/New_York, causing all cron jobs to fire 4 hours early during EDT (UTC-4).

+
+ + + +
+
Diagnostic commands run & why
+ +
+
1 Checked backup log timestamps
+
ls -lt /var/log/plex32-backup/
+

Confirm the 10 PM run actually happened and check if it was recurring.

+

Showed Jun 19 ran at 23:17, Jun 20 at 02:03, Jun 22 at 02:09 — pattern inconsistent, not a simple crontab typo.

+
+ +
+
2 Verified crontab entry on PLEX32
+
sudo crontab -l
+

Rule out a bad cron expression as the cause.

+

Crontab was correct: 0 2 */3 * * — not the problem.

+
+ +
+
3 Checked system timezone
+
timedatectl
+

A 4-hour offset is the classic symptom of UTC vs EDT mismatch.

+

Time zone: Etc/UTC — confirmed root cause. Cron was firing at 2 AM UTC = 10 PM EDT.

+
+ +
+
4 Checked timezones on all other VLAN 50 servers
+
ssh rufusking@192.168.150.35 "timedatectl | grep 'Time zone'"   # NAS08
+ssh rufusking@192.168.150.40 "timedatectl | grep 'Time zone'"   # NAS16
+ssh rufusking@192.168.150.30 "timedatectl | grep 'Time zone'"   # HAS
+

If PLEX32 had the wrong timezone, other servers might too.

+

All other servers were already on America/New_York — PLEX32 was the only one affected.

+
+ +
+ + + +
+
Root cause — cron firing at wrong time
+ +
+

PLEX32's system timezone was set to Etc/UTC (the Debian default). All cron jobs fired at the correct UTC time, which is 4 hours ahead of Eastern Daylight Time, causing the 2 AM backup to run at 10 PM EDT.

+
+ +
+
⚠ Key clue
+

The backup log on Jun 19 showed 23:17 and Jun 20 showed 02:03 — the jump from late-night to early-morning suggested the timezone was corrected mid-cycle, but timedatectl showed it was still UTC. The log timestamps themselves are in UTC, so both runs were actually at 2 AM UTC.

+
-rw-r--r-- 1 root      root      1559 Jun 22 02:09 backup-20260622.log
+-rw-rw-r-- 1 rufusking rufusking 3120 Jun 20 02:03 backup-20260620.log
+-rw-rw-r-- 1 rufusking rufusking 5879 Jun 19 23:17 backup-20260619.log
+

The Jun 19 timestamp difference was due to the backup running at a slightly different interval that cycle — not a timezone change.

+
+ +
+

Resolution: Set the timezone to America/New_York with sudo timedatectl set-timezone America/New_York. No crontab changes needed — cron reads system time automatically.

+
+
+ + + +
+
New capability — PLEX32 remote reboot script
+ +

A remote reboot script was created on NAS08 to provide a way to reboot PLEX32 from outside the home network via OMV's Scheduled Jobs interface. The script was iteratively debugged through several test runs.

+ +
+ nc not installed on NAS08 — port check always failed + docker compose down clears restart policy — containers didn't auto-start + flat sleep 60 caused unnecessary delay in Plex detection +
+ +
+
1 Verified SSH key from NAS08 root → PLEX32 already existed
+
sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub rufusking@192.168.150.45
+sudo ssh -i /root/.ssh/id_ed25519 rufusking@192.168.150.45 "echo OK"
+

Key was already trusted — ssh-copy-id skipped it, echo OK confirmed passwordless access.

+
+ +
+
2 Added reboot to PLEX32 sudoers
+
sudo visudo -f /etc/sudoers.d/rufusking-nopasswd
+# Appended: /sbin/reboot
+

The script runs sudo reboot over SSH — must be passwordless or it hangs.

+

Full sudoers line: apt-get update, fail2ban-client status, fail2ban-client status sshd, /sbin/reboot

+
+ +
+
3 First test — containers didn't restart after reboot
+
docker ps
+# Result: empty — no containers running
+

The script ran docker compose down before rebooting, which clears the unless-stopped restart policy. Nothing brought the containers back up.

+

Plex was down after reboot — had to manually run docker compose up -d.

+
+ +
+
4 Fixed — added docker compose up -d after PLEX32 comes back online
+
ssh ... "cd ~/docker/plex && docker compose up -d"
+

Containers started cleanly after subsequent reboots.

+
+ +
+
5 Port check always reported failure despite Plex being up
+
nc -z -w 5 192.168.150.45 32400; echo "nc exit: $?"
+# Result: nc exit: 127 (command not found)
+

nc (netcat) is not installed on NAS08 — the check always returned failure regardless of Plex state.

+

Switched to curl HTTP check — confirmed Plex returns HTTP 302 on port 32400.

+
+ +
+
6 Replaced flat sleep 60 with polling loop
+
while [ $PLEX_ELAPSED -lt 90 ]; do
+  HTTP_CODE=$(curl ... http://192.168.150.45:32400/web)
+  [ "$HTTP_CODE" = "302" ] && break
+  sleep 5; PLEX_ELAPSED=$((PLEX_ELAPSED + 5))
+done
+

Plex was up within ~10s of containers starting but the script was waiting a full 60s before checking.

+

Plex confirmed responding after 10s on final test run.

+
+ +
+ + + +
+
Script created
+
+
+
Location (NAS08)
+
/usr/scripts/nas/plex32_reboot.sh
+
+
+
Log directory
+
/var/log/plex32-reboot/reboot-YYYYMMDD-HHMMSS.log  (90 day retention)
+
+
+
What it does
+
1. SSH to PLEX32 → docker compose down (graceful container stop)
+2. SSH to PLEX32 → sudo reboot
+3. Wait 30s then poll ping every 10s (max 3 min) until PLEX32 responds
+4. SSH to PLEX32 → docker compose up -d
+5. Poll curl http://192.168.150.45:32400/web every 5s (max 90s)
+6. Log success (HTTP 302/200/301) or warning if timeout
+
+
+
OMV Scheduled Job
+
Tag: PLEX32 Remote Reboot
+Command: /usr/scripts/nas/plex32_reboot.sh
+Enabled: OFF — enable manually, run once, disable again
+
+
+
Final test run result
+
[23:40:39] PLEX32 Remote Reboot — Initiated
+[23:40:45] Docker containers stopped cleanly.
+[23:40:45] Reboot command sent. PLEX32 is going down.
+[23:41:27] PLEX32 is back online (ping responded after ~40s).
+[23:41:30] Docker containers started.
+[23:41:40] Plex is responding (HTTP 302) after 10s — reboot successful.
+
+
+
+ + + +
+
Lessons learned
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
LessonDetail
Always set timezone on new installsDebian/Ubuntu default is Etc/UTC. Run sudo timedatectl set-timezone America/New_York immediately after setup. Verify with timedatectl | grep "Time zone". A 4-hour cron offset is the telltale symptom.
docker compose down clears restart policyUnlike a system reboot (which respects restart: unless-stopped), an explicit compose down removes containers entirely. Always follow a pre-reboot compose down with a post-boot compose up -d.
nc is not installed on NAS08Use curl -s -o /dev/null -w "%{http_code}" for HTTP service checks from NAS08. Plex returns HTTP 302 on port 32400 — treat 200, 301, and 302 all as success.
Flat sleep before health checks wastes timeReplace sleep N before a readiness check with a polling loop. Check every 5 seconds, break on success, cap with a max timeout. Services often come up well before the sleep expires.
Verify deployed scripts after editingUse grep -n "key_string" /path/to/script to confirm the correct version is on disk before testing. Copy errors and stale files caused multiple confusing test failures in this session.
+
+ + + +
+
Documentation updated
+ + + + + + + + + + + + + + +
FileWhat changed
server_plex32.mdAdded timezone section (was UTC, corrected to America/New_York). Updated sudoers entry to include /sbin/reboot. Updated AI summary with timezone note and NAS08→PLEX32 SSH key entry.
server_nas08.mdAdded plex32_reboot.sh to OMV Scheduled Jobs table (disabled/manual). Added NAS08 root → PLEX32 SSH key note. Updated AI summary with remote reboot script details.
+
+ + + + +
+ + diff --git a/PLEX32-Launcher.desktop b/PLEX32-Launcher.desktop new file mode 100644 index 0000000..f2fa12c --- /dev/null +++ b/PLEX32-Launcher.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=PLEX32 Launcher +Comment=Run scripts on PLEX32 remotely +Exec=bash -c "gnome-terminal -- bash /home/rufusking/scripts/plex32_launcher.sh" +Icon=utilities-terminal +Terminal=false +Categories=Network;System; diff --git a/PLEX32-Optimization-Summary.html b/PLEX32-Optimization-Summary.html new file mode 100644 index 0000000..a390f30 --- /dev/null +++ b/PLEX32-Optimization-Summary.html @@ -0,0 +1,608 @@ + + + + + +KingDezigns — PLEX32 Optimization Summary + + + + + +
+ +
+

KingDezigns — PLEX32 Optimization & Setup Summary

+
+ 📅 June 19–20, 2026 + 🖥 PLEX32 — Dell Wyse 5070 + ⚙️ Ubuntu 24.04 + 🐳 Docker Compose + ☁️ Plex Media Server + Tautulli +
+
+ + + +
+
Overview
+ +

PLEX32 was recently migrated from NAS08 to a dedicated Dell Wyse 5070 (Intel Celeron J4105, 8GB RAM) to support Plex Pass features including Sonic Radio that require x86 CPU capabilities. This session covered a full 7-step optimization of the new server — hardware transcoding, playback tuning, Tautulli setup, automated backup, scheduled maintenance, and subtitle handling.

+ +
+ Hardware Transcoding — Enabled + Tautulli — Installed + Backup — Automated + SSH Keys — Configured +
+ +

All 7 optimization steps completed successfully. PLEX32 is now fully configured and production-ready.

+
+ + + +
+
Step 1 — Hardware transcoding (Intel Quick Sync)
+ +

The J4105 has Intel Quick Sync but the Plex Docker container had no access to /dev/dri and was silently falling back to software transcoding on every stream.

+ +
+
1 Confirmed iGPU is exposed on the host
+
ls -la /dev/dri
+

card0 (video, GID 44) and renderD128 (render, GID 993) both present

+
+ +
+
2 Updated docker-compose.yml — added device passthrough and group_add
+
devices:
+  - /dev/dri:/dev/dri
+group_add:
+  - "44"
+  - "993"
+

Container needs both the device node and supplementary group membership to access the GPU

+

docker exec confirmed groups 44 and 993 in container process — /dev/dri visible inside container

+
+ +
+
3 Enabled hardware transcoding in Plex UI
+
Settings → Transcoder → Use hardware acceleration when available ✅
+Settings → Transcoder → Use hardware-accelerated video encoding ✅
+

Both were already checked — confirming the settings were attempting HW transcode but failing silently due to missing device access

+

Dashboard showed: 4K (HEVC Main 10) (hw) / SD (HEVC) — Transcode (hw) — both sides of pipeline on GPU

+
+ +
+
4 Set transcoder temp directory
+
Settings → Transcoder → Transcoder temporary directory: /transcode
+

Was empty — Plex was writing transcode scratch files to uncontrolled location inside container filesystem

+

Now maps to /opt/plex/transcode on host via existing volume mount

+
+
+ + + +
+
Step 2 — Direct Play / Direct Stream tuning
+ +

Library codec analysis revealed 90 HEVC Main 10 files (~75%) and 29 H.264 files (~25%), all with AAC audio. Roku TV (Hisense 55R8BX) was tested — video Direct Streams, audio converts AAC → AC3 for HDMI passthrough.

+ +
+
1 Codec analysis — video
+
find "/mnt/plex/media/04 - Videos/Movies" \( -iname "*.mkv" -o -iname "*.mp4" \) | while read -r f; do
+  ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,profile \
+  -of csv=p=0 "$f" 2>/dev/null
+done | sort | uniq -c | sort -rn
+

82+8 hevc,Main 10 / 29 h264,High — library is 75% HEVC 10-bit

+
+ +
+
2 Codec analysis — audio
+
find ... | ffprobe ... -select_streams a:0 ... | sort | uniq -c
+

119 aac — 100% AAC audio across entire library. No TrueHD, no DTS. Audio will never force a transcode due to format incompatibility.

+
+ +
+
3 Live playback test on Roku
+
Plex Dashboard → active session while playing on Roku TV
+

Video: 4K (HEVC Main 10) — Direct Stream ✅ / Audio: English (AAC 5.1) → AC3 Transcode (lightweight, expected for Roku HDMI passthrough)

+
+ +
+

Result: Video never transcodes. Audio does a lightweight AAC→AC3 conversion for Roku surround output. Optimal for this setup.

+
+
+ + + +
+
Steps 3 & 4 — Sonic Radio + Tautulli
+ +

Sonic Radio was confirmed working — the whole reason for migrating to PLEX32. Opening any artist in the Music library and clicking Live Radio plays that artist then branches into similar artists using AI-driven discovery. Requires x86 CPU — confirmed working on J4105.

+ +

Tautulli was installed as a second service in the existing docker-compose.yml, running on port 8181. Connected to Plex, email notification configured for Plex server down/up alerts using existing Zoho SMTP credentials.

+ +
+
+
Tautulli added to docker-compose.yml
+
tautulli:
+  image: lscr.io/linuxserver/tautulli:latest
+  container_name: tautulli
+  environment:
+    - PUID=1000
+    - PGID=1001
+    - TZ=America/New_York
+  volumes:
+    - /opt/tautulli/config:/config
+  ports:
+    - 8181:8181
+  restart: unless-stopped
+
+
+
Config directory
+
/opt/tautulli/config
+
+
+
Web UI
+
http://192.168.150.45:8181
+
+
+
Notification triggers set
+
Plex Server Down + Plex Server Back Up → email to rufus.king@kingdezigns.com
+
+
+
+ + + +
+
Step 5 — Automated config backup
+ +

A backup script was created to archive Plex and Tautulli configs to NAS08 via NFS every 3 days. rsync was attempted first but failed due to NFS directory scanning performance issues and permission constraints from NAS08's all_squash export. The final approach uses tar locally then a single cp to NFS.

+ +
+
⚠ Key issue — rsync over NFS hung indefinitely
+

rsync hung at "sending incremental file list" because scanning thousands of small Plex metadata files over NFS was extremely slow. Additionally NAS08's all_squash NFS export (anonuid=33/anongid=1001) prevented rufusking on PLEX32 from writing new files to the destination until directory permissions were corrected on NAS08.

+
sudo chown -R www-data:ncshare /export/kingdezigns-all/Docker/plex/config
+sudo chmod -R 2770 /export/kingdezigns-all/Docker/plex/config
+

Final solution: tar to /tmp locally (no NFS), then single cp to NFS. NFS write speed confirmed at 100 MB/s. Total backup runtime ~10 minutes.

+
+ +
+
+
Backup script location
+
/usr/scripts/plex32/plex32_backup.sh
+
+
+
Schedule
+
0 2 */3 * * /usr/scripts/plex32/plex32_backup.sh    # Every 3 days at 2:00 AM
+
+
+
Plex archive destination (NAS08)
+
/export/kingdezigns-all/Docker/plex/config/backups/plex/
+plex-config-YYYY-MM-DD.tar.gz    (3 copies retained)
+
+
+
Tautulli archive destination (NAS08)
+
/export/kingdezigns-all/Docker/plex/config/backups/tautulli/
+tautulli-config-YYYY-MM-DD.tar.gz    (3 copies retained)
+
+
+
Excludes from Plex archive
+
Cache / Logs / Crash Reports
+
+
+
SMTP password file
+
/etc/plex32-smtp-pass    (chmod 600, owned by rufusking)
+
+
+
Restore procedure (run on plex32)
+
cd ~/docker/plex && docker compose down
+cp /mnt/plex/config/backups/plex/plex-config-YYYY-MM-DD.tar.gz /tmp/
+sudo rm -rf /opt/plex/config/Library
+sudo tar -xzf /tmp/plex-config-YYYY-MM-DD.tar.gz -C /opt/plex
+sudo chown -R rufusking:ncshare /opt/plex/config
+cd ~/docker/plex && docker compose up -d
+docker ps    # verify both plex and tautulli show Up
+# Open http://192.168.150.45:32400/web and confirm library intact
+rm /tmp/plex-config-YYYY-MM-DD.tar.gz
+
+
+
+ + + +
+
Remote launcher + SSH key authentication
+ +

A launcher script was created on KingDezigns001 to remotely execute PLEX32 scripts via SSH with a simple menu. SSH key authentication was configured from KingDezigns001 to all VLAN 50 servers, and from PLEX32 to NAS08, eliminating all password prompts.

+ +
+
+
Launcher script (KingDezigns001)
+
~/scripts/plex32_launcher.sh
+
+
+
Desktop shortcut (KingDezigns001)
+
~/Desktop/PLEX32-Launcher.desktop
+
+
+
Menu options
+
1) Update Check      → /usr/scripts/plex32/plex32_update_check.sh
+2) Health Report     → /usr/scripts/plex32/plex32_health_report.sh
+3) Plex32 Backup     → /usr/scripts/plex32/plex32_backup.sh
+
+
+
SSH keys configured (no password)
+
KingDezigns001 → PLEX32   (192.168.150.45)
+KingDezigns001 → NAS08    (192.168.150.35)
+KingDezigns001 → NAS16    (192.168.150.40)
+PLEX32         → NAS08    (192.168.150.35)    # required for backup disk stats
+
+
+
sudo without password (plex32) — /etc/sudoers.d/rufusking-nopasswd
+
rufusking ALL=(ALL) NOPASSWD: /usr/bin/apt-get update, /usr/bin/fail2ban-client status, /usr/bin/fail2ban-client status sshd
+
+
+
+ + + +
+
Steps 6 & 7 — Scheduled maintenance & subtitle handling
+ +

Scheduled maintenance was already configured to run between 2:00 AM and 5:00 AM. The following library analysis tasks were confirmed enabled as scheduled tasks:

+ + + + + + + + + + + + + + +
TaskSetting
Generate video preview thumbnailsAs a scheduled task (changed from Never)
Generate chapter image thumbnailsAs a scheduled task
Analyze audio tracks for loudnessAs a scheduled task
Generate intro video markersAs a scheduled task and when media is added
Generate credits video markersAs a scheduled task and when media is added
Analyze audio tracks for sonic featuresAs a scheduled task
Generate voice activity dataAs a scheduled task (changed from Never)
+ +

Subtitle handling: Library contains 1168 SRT (safe, no transcode) and 279 PGS image-based subtitles (burn-in forces transcode if selected). Since subtitles are used occasionally and Quick Sync handles the transcode efficiently, Option B (bulk PGS→SRT conversion) was not pursued. Subtitle Mode confirmed set to Manually selected — subtitles only appear when explicitly chosen, preventing accidental burn-in transcoding.

+
+ + + +
+
Lessons learned
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LessonDetail
GPU device passthrough requires group membershipAdding /dev/dri to Docker devices is not enough — the container process also needs supplementary groups matching the video (44) and render (993) GIDs on the host. Use group_add in docker-compose.
rsync over NFS is unreliable for large Plex configsThousands of small metadata files cause rsync to hang indefinitely building the file list over NFS. Use tar locally then a single cp to NFS instead — much faster and more reliable.
NAS08 all_squash NFS exports require correct directory ownershipEven sudo on PLEX32 cannot create files on NFS mounts with all_squash unless the destination directory on NAS08 is owned by www-data:ncshare with 2770 permissions.
Scripts running as non-root need sudo for apt-get and fail2ban-clientBoth commands require root access. Grant specific NOPASSWD sudo rights via /etc/sudoers.d/ rather than running the whole script as root.
SSH keys should be set up on all servers from the startKey authentication eliminates password prompts for both interactive SSH and script-to-script SSH calls (e.g. PLEX32 → NAS08 for disk stats). Generate once per machine, copy to all targets.
Hardware transcoding silently falls back without /dev/dri accessPlex showed hardware transcoding as "enabled" in settings but was silently using software transcoding. Always verify with the Dashboard showing (hw) next to the codec during an active transcode.
AAC audio across the entire library is idealTrueHD and DTS-HD MA are the common culprits that force audio transcoding on most clients. A library with 100% AAC will never trigger an audio-forced transcode regardless of client.
+
+ + + +
+
Network docs — items to update
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemOld valueNew value
server_plex32.md — docker-compose.ymlNo devices or group_add blockdevices: /dev/dri + group_add: ["44","993"] + Tautulli service added
server_plex32.md — Scheduled Jobs2 cron jobs (update check, health report)3 cron jobs — add: 0 2 */3 * * /usr/scripts/plex32/plex32_backup.sh
server_plex32.md — new scripts sectionNo backup scriptplex32_backup.sh — tar + NFS copy to NAS08, 3 copies retained, HTML email report
server_plex32.md — SSH keysNot documentedPLEX32 has SSH key to NAS08 (192.168.150.35) — required for backup disk stats
server_plex32.md — sudoersNot documented/etc/sudoers.d/rufusking-nopasswd — apt-get update + fail2ban-client commands
server_plex32.md — TautulliNot presentTautulli running on port 8181, config at /opt/tautulli/config, Plex down/up alerts active
network_index.md — KingDezigns001 toolsNot documentedplex32_launcher.sh at ~/scripts/ + PLEX32-Launcher.desktop on Desktop — SSH keys to all VLAN 50 servers
server_nas08.md — NFS export permissionsNot documented/export/kingdezigns-all/Docker/plex/config — www-data:ncshare 2770, backups/plex and backups/tautulli subdirs created
+
+ + + + +
+ + diff --git a/plex32_backup.sh b/plex32_backup.sh new file mode 100644 index 0000000..9eb9d3a --- /dev/null +++ b/plex32_backup.sh @@ -0,0 +1,471 @@ +#!/bin/bash +# ============================================================= +# PLEX32 — Config Backup Script +# Creates tar archives of Plex and Tautulli configs +# Copies to NAS08 via NFS as single files +# Keeps last 3 backups, deletes older ones +# Sends HTML email report in KingDezigns style +# Location: /usr/scripts/plex32/plex32_backup.sh +# Schedule: Every 3 days at 2:00 AM (cron) +# ============================================================= + +# --- Config --- +HOSTNAME="plex32" +PLEX_SRC="/opt/plex/config" +TAUTULLI_SRC="/opt/tautulli/config" +NFS_DEST="/mnt/plex/config" +TAUTULLI_LOC="backups/tautulli" +PLEX_LOC="backups/plex" + +TMP_DIR="/tmp/plex32-backup" +LOG_DIR="/var/log/plex32-backup" +LOG_FILE="$LOG_DIR/backup-$(date +%Y%m%d).log" +RETENTION_COUNT=3 +COMPOSE_DIR="/home/rufusking/docker/plex" + +DATESTAMP=$(date '+%Y-%m-%d') +TIMESTAMP=$(date '+%B %d, %Y at %I:%M %p') + +PLEX_ARCHIVE="plex-config-${DATESTAMP}.tar.gz" +TAUTULLI_ARCHIVE="tautulli-config-${DATESTAMP}.tar.gz" + +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT=465 +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS="$(cat /etc/plex32-smtp-pass 2>/dev/null)" +EMAIL_TO="rufus.king@kingdezigns.com" +EMAIL_FROM="rufus.king@kingdezigns.com" + +# --- Setup --- +mkdir -p "$LOG_DIR" "$TMP_DIR" +START_TIME=$(date +%s) + +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] PLEX32 backup started" + +# --- Check NFS mount --- +if ! mountpoint -q "$NFS_DEST"; then + echo "[ERROR] NFS mount not available: $NFS_DEST" + exit 1 +fi + +# --- Stop containers --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Stopping Plex and Tautulli containers..." +cd "$COMPOSE_DIR" && docker compose down +STOP_STATUS=$? +if [ $STOP_STATUS -ne 0 ]; then + echo "[ERROR] Failed to stop containers cleanly" +fi + +# --- Tar Plex config --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating Plex config archive..." +PLEX_START=$(date +%s) +tar -czf "$TMP_DIR/$PLEX_ARCHIVE" \ + --exclude="config/Library/Application Support/Plex Media Server/Cache" \ + --exclude="config/Library/Application Support/Plex Media Server/Logs" \ + --exclude="config/Library/Application Support/Plex Media Server/Crash Reports" \ + -C /opt/plex config 2>&1 +PLEX_TAR_STATUS=$? +PLEX_END=$(date +%s) +PLEX_DURATION=$((PLEX_END - PLEX_START)) + +if [ $PLEX_TAR_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Plex archive created in ${PLEX_DURATION}s" +else + echo "[ERROR] Plex tar failed with status $PLEX_TAR_STATUS" +fi + +# --- Tar Tautulli config --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating Tautulli config archive..." +TAUTULLI_START=$(date +%s) +tar -czf "$TMP_DIR/$TAUTULLI_ARCHIVE" \ + -C /opt/tautulli config 2>&1 +TAUTULLI_TAR_STATUS=$? +TAUTULLI_END=$(date +%s) +TAUTULLI_DURATION=$((TAUTULLI_END - TAUTULLI_START)) + +if [ $TAUTULLI_TAR_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tautulli archive created in ${TAUTULLI_DURATION}s" +else + echo "[ERROR] Tautulli tar failed with status $TAUTULLI_TAR_STATUS" +fi + +# --- Restart containers --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restarting containers..." +cd "$COMPOSE_DIR" && docker compose up -d +START_STATUS=$? +if [ $START_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Containers restarted successfully" +else + echo "[ERROR] Failed to restart containers on first attempt — retrying in 15 seconds..." + sleep 15 + cd "$COMPOSE_DIR" && docker compose up -d + START_STATUS=$? + if [ $START_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Containers restarted successfully on retry" + else + echo "[ERROR] Containers failed to restart after retry — manual intervention required" + fi +fi + +# --- Verify containers are actually running --- +sleep 5 +PLEX_RUNNING=$(docker ps --filter "name=plex" --filter "status=running" --format "{{.Names}}" | grep -c "^plex$") +TAUTULLI_RUNNING=$(docker ps --filter "name=tautulli" --filter "status=running" --format "{{.Names}}" | grep -c "^tautulli$") + +if [ "$PLEX_RUNNING" -eq 1 ]; then + PLEX_CONTAINER_STATUS="✅ RUNNING" + PLEX_CONTAINER_COLOR="#166534" + PLEX_CONTAINER_BG="#f0fdf4" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Plex container verified running" +else + PLEX_CONTAINER_STATUS="❌ NOT RUNNING — MANUAL INTERVENTION REQUIRED" + PLEX_CONTAINER_COLOR="#b91c1c" + PLEX_CONTAINER_BG="#fff1f2" + echo "[ERROR] Plex container is NOT running after backup" +fi + +if [ "$TAUTULLI_RUNNING" -eq 1 ]; then + TAUTULLI_CONTAINER_STATUS="✅ RUNNING" + TAUTULLI_CONTAINER_COLOR="#166534" + TAUTULLI_CONTAINER_BG="#f0fdf4" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tautulli container verified running" +else + TAUTULLI_CONTAINER_STATUS="❌ NOT RUNNING — MANUAL INTERVENTION REQUIRED" + TAUTULLI_CONTAINER_COLOR="#b91c1c" + TAUTULLI_CONTAINER_BG="#fff1f2" + echo "[ERROR] Tautulli container is NOT running after backup" +fi + +# --- Copy Plex archive to NAS08 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Copying Plex archive to NAS08..." +COPY_PLEX_START=$(date +%s) +cp "$TMP_DIR/$PLEX_ARCHIVE" "$NFS_DEST/$PLEX_LOC/" +PLEX_COPY_STATUS=$? +COPY_PLEX_END=$(date +%s) +PLEX_COPY_DURATION=$((COPY_PLEX_END - COPY_PLEX_START)) + +if [ $PLEX_COPY_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Plex archive copied in ${PLEX_COPY_DURATION}s" +else + echo "[ERROR] Plex archive copy failed" +fi + +# --- Copy Tautulli archive to NAS08 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Copying Tautulli archive to NAS08..." +COPY_TAUTULLI_START=$(date +%s) +cp "$TMP_DIR/$TAUTULLI_ARCHIVE" "$NFS_DEST/$TAUTULLI_LOC/" +TAUTULLI_COPY_STATUS=$? +COPY_TAUTULLI_END=$(date +%s) +TAUTULLI_COPY_DURATION=$((COPY_TAUTULLI_END - COPY_TAUTULLI_START)) + +if [ $TAUTULLI_COPY_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tautulli archive copied in ${TAUTULLI_COPY_DURATION}s" +else + echo "[ERROR] Tautulli archive copy failed" +fi + +# --- Rotate old backups — keep last 3 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Rotating old backups..." +ls -t "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | tail -n +$((RETENTION_COUNT + 1)) | xargs rm -f +ls -t "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | tail -n +$((RETENTION_COUNT + 1)) | xargs rm -f + +# --- Cleanup temp files --- +rm -f "$TMP_DIR/$PLEX_ARCHIVE" "$TMP_DIR/$TAUTULLI_ARCHIVE" + +# --- Sizes and storage --- +PLEX_ARCHIVE_SIZE=$(ls -lh "$NFS_DEST/$PLEX_LOC/$PLEX_ARCHIVE" 2>/dev/null | awk '{print $5}') +TAUTULLI_ARCHIVE_SIZE=$(ls -lh "$NFS_DEST/$TAUTULLI_LOC/$TAUTULLI_ARCHIVE" 2>/dev/null | awk '{print $5}') +PLEX_BACKUP_COUNT=$(ls "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | wc -l) +TAUTULLI_BACKUP_COUNT=$(ls "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | wc -l) + +# Get accurate NAS08 disk stats via SSH (avoids NFS dataset quota confusion) +NAS08_DISK_INFO=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 rufusking@192.168.150.35 \ + "df -h /export/kingdezigns-all | awk 'NR==2{print \$3\" used / \"\$2\" total / \"\$4\" free\"}'" 2>/dev/null) +if [ -z "$NAS08_DISK_INFO" ]; then + # Fallback to NFS mount stats if SSH fails + NAS08_DISK_INFO=$(df -h "$NFS_DEST" 2>/dev/null | awk 'NR==2{print $3" used / "$2" total / "$4" free"}') +fi + +# List of current backups on NAS08 +PLEX_BACKUP_LIST=$(ls -lh "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | \ + awk '{print $NF" ("$5")"}' | xargs -I{} basename {} 2>/dev/null | \ + while read -r line; do echo "$line"; done) +TAUTULLI_BACKUP_LIST=$(ls -lh "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | \ + awk '{print $NF" ("$5")"}' | xargs -I{} basename {} 2>/dev/null | \ + while read -r line; do echo "$line"; done) + +# Build backup list HTML rows +PLEX_LIST_ROWS="" +while IFS= read -r f; do + SIZE=$(ls -lh "$NFS_DEST/$PLEX_LOC/$f" 2>/dev/null | awk '{print $5}') + PLEX_LIST_ROWS="${PLEX_LIST_ROWS}$f$SIZE" +done < <(ls "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | xargs -I{} basename {}) + +TAUTULLI_LIST_ROWS="" +while IFS= read -r f; do + SIZE=$(ls -lh "$NFS_DEST/$TAUTULLI_LOC/$f" 2>/dev/null | awk '{print $5}') + TAUTULLI_LIST_ROWS="${TAUTULLI_LIST_ROWS}$f$SIZE" +done < <(ls "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | xargs -I{} basename {}) + +# --- Total duration --- +END_TIME=$(date +%s) +TOTAL_DURATION=$((END_TIME - START_TIME)) +TOTAL_MIN=$((TOTAL_DURATION / 60)) +TOTAL_SEC=$((TOTAL_DURATION % 60)) + +# --- Overall status --- +PLEX_STATUS=$((PLEX_TAR_STATUS + PLEX_COPY_STATUS)) +TAUTULLI_STATUS=$((TAUTULLI_TAR_STATUS + TAUTULLI_COPY_STATUS)) +CONTAINER_STATUS=$((PLEX_RUNNING == 1 && TAUTULLI_RUNNING == 1 ? 0 : 1)) + +if [ $PLEX_STATUS -eq 0 ] && [ $TAUTULLI_STATUS -eq 0 ] && [ $CONTAINER_STATUS -eq 0 ]; then + OVERALL_STATUS="SUCCESS" + STATUS_COLOR="#1a7f4b" + BANNER_ICON="✅" + BANNER_MSG="Backup completed successfully — Plex and Tautulli configs archived to NAS08" + BADGE_COLOR="#1a7f4b" +elif [ $PLEX_STATUS -ne 0 ] && [ $TAUTULLI_STATUS -ne 0 ]; then + OVERALL_STATUS="FAILED" + STATUS_COLOR="#b91c1c" + BANNER_ICON="🔴" + BANNER_MSG="Backup failed — both archive operations encountered errors" + BADGE_COLOR="#b91c1c" +else + OVERALL_STATUS="PARTIAL" + STATUS_COLOR="#b45309" + BANNER_ICON="⚠️" + BANNER_MSG="Backup partially completed — review individual results below" + BADGE_COLOR="#b45309" +fi + +# --- Status labels --- +if [ $PLEX_STATUS -eq 0 ]; then + PLEX_LABEL="✅ SUCCESS" + PLEX_ROW_BG="#f0fdf4" + PLEX_TEXT_COLOR="#166534" +else + PLEX_LABEL="❌ FAILED" + PLEX_ROW_BG="#fff1f2" + PLEX_TEXT_COLOR="#b91c1c" +fi + +if [ $TAUTULLI_STATUS -eq 0 ]; then + TAUTULLI_LABEL="✅ SUCCESS" + TAUTULLI_ROW_BG="#f0fdf4" + TAUTULLI_TEXT_COLOR="#166534" +else + TAUTULLI_LABEL="❌ FAILED" + TAUTULLI_ROW_BG="#fff1f2" + TAUTULLI_TEXT_COLOR="#b91c1c" +fi + +# --- Send email --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Sending email report..." + +python3 << PYEOF +import smtplib, ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +smtp_server = "$SMTP_SERVER" +smtp_port = $SMTP_PORT +smtp_user = "$SMTP_USER" +smtp_pass = "$SMTP_PASS" +email_to = "$EMAIL_TO" +email_from = "$EMAIL_FROM" + +subject = "PLEX32 Backup — $OVERALL_STATUS — $DATESTAMP" + +html = """ + + + + + +
+ + + + + + + + + + + + + + +
+ + + +
+
Config Backup
+
🎬 plex32
+
$TIMESTAMP  |  VLAN 50 — Lab/Servers
+
+
$OVERALL_STATUS
+
+
+ $BANNER_ICON  $BANNER_MSG +
+ + + + + + + + + +
+
+
Duration
+
${TOTAL_MIN}m ${TOTAL_SEC}s
+
+
+
+
Plex Archive
+
$PLEX_ARCHIVE_SIZE
+
+
+
+
Tautulli Archive
+
$TAUTULLI_ARCHIVE_SIZE
+
+
+
+
Plex Copies
+
$PLEX_BACKUP_COUNT / $RETENTION_COUNT
+
+
+ + +
+
📋 Backup Results
+ + + + + + + + + + + + + + + + + + + +
ComponentArchiveDurationStatus
🎬 Plex Config$PLEX_ARCHIVE${PLEX_DURATION}s tar + ${PLEX_COPY_DURATION}s copy$PLEX_LABEL
📊 Tautulli Config$TAUTULLI_ARCHIVE${TAUTULLI_DURATION}s tar + ${TAUTULLI_COPY_DURATION}s copy$TAUTULLI_LABEL
+
+ + +
+
🐳 Container Status After Backup
+ + + + + + + + + +
🎬 Plex$PLEX_CONTAINER_STATUS
📊 Tautulli$TAUTULLI_CONTAINER_STATUS
+
+ + +
+
🗄️ Archives on NAS08
+ + + + + $PLEX_LIST_ROWS + + + + $TAUTULLI_LIST_ROWS +
Plex Backups (max $RETENTION_COUNT kept)
Tautulli Backups (max $RETENTION_COUNT kept)
+
+ + +
+ Backup Destination + NAS08 (192.168.150.35) — $NAS08_DISK_INFO +
+ + +
+
💡 How to Restore Plex Config
+
+ +
Machine: plex32 (192.168.150.45)
+ +
Step 1 — Stop Plex and Tautulli
+
cd ~/docker/plex && docker compose down
+ +
Step 2 — Copy archive from NAS08 to PLEX32
+
cp /mnt/plex/config/$PLEX_LOC/$PLEX_ARCHIVE /tmp/
+ +
Step 3 — Clear current Plex config
+
sudo rm -rf /opt/plex/config/Library
+ +
Step 4 — Extract the backup
+
sudo tar -xzf /tmp/$PLEX_ARCHIVE -C /opt/plex
+sudo chown -R rufusking:ncshare /opt/plex/config
+ +
Step 5 — Restart containers
+
cd ~/docker/plex && docker compose up -d
+ +
Step 6 — Verify both containers are running
+
docker ps
+
Both plex and tautulli should show status Up. Then open http://192.168.150.45:32400/web and confirm your library and watch history are intact.
+ +
Step 7 — Cleanup temp file
+
rm /tmp/$PLEX_ARCHIVE
+ +
+
+ +
+ Config Backup  |  plex32  |  KingDezigns Infrastructure +
+
+ + +""" + +msg = MIMEMultipart("alternative") +msg["Subject"] = subject +msg["From"] = email_from +msg["To"] = email_to +msg.attach(MIMEText(html, "html")) + +ctx = ssl.create_default_context() +with smtplib.SMTP_SSL(smtp_server, smtp_port, context=ctx, timeout=15) as s: + s.login(smtp_user, smtp_pass) + s.sendmail(email_from, [email_to], msg.as_string()) +print("Email sent successfully") +PYEOF + +EMAIL_STATUS=$? +if [ $EMAIL_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Email sent successfully" +else + echo "[ERROR] Email send failed" +fi + +# --- Cleanup old logs --- +find "$LOG_DIR" -name "backup-*.log" -mtime +90 -delete 2>/dev/null + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup complete — Status: $OVERALL_STATUS | Duration: ${TOTAL_MIN}m ${TOTAL_SEC}s" diff --git a/plex32_backup2.sh b/plex32_backup2.sh new file mode 100644 index 0000000..af06a64 --- /dev/null +++ b/plex32_backup2.sh @@ -0,0 +1,365 @@ +#!/bin/bash +# ============================================================= +# PLEX32 — Config Backup Script +# Creates tar archives of Plex and Tautulli configs +# Copies to NAS08 via NFS as single files +# Keeps last 3 backups, deletes older ones +# Sends HTML email report in KingDezigns style +# Location: /usr/scripts/plex32/plex32_backup.sh +# Schedule: Every 3 days at 2:00 AM (cron) +# ============================================================= + +# --- Config --- +HOSTNAME="plex32" +PLEX_SRC="/opt/plex/config" +TAUTULLI_SRC="/opt/tautulli/config" +NFS_DEST="/mnt/plex/config" +TAUTULLI_LOC="backups/tautulli" +PLEX_LOC="backups/plex" + +TMP_DIR="/tmp/plex32-backup" +LOG_DIR="/var/log/plex32-backup" +LOG_FILE="$LOG_DIR/backup-$(date +%Y%m%d).log" +RETENTION_COUNT=3 +COMPOSE_DIR="/home/rufusking/docker/plex" + +DATESTAMP=$(date '+%Y-%m-%d') +TIMESTAMP=$(date '+%B %d, %Y at %I:%M %p') + +PLEX_ARCHIVE="plex-config-${DATESTAMP}.tar.gz" +TAUTULLI_ARCHIVE="tautulli-config-${DATESTAMP}.tar.gz" + +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT=465 +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS="$(cat /etc/plex32-smtp-pass 2>/dev/null)" +EMAIL_TO="rufus.king@kingdezigns.com" +EMAIL_FROM="rufus.king@kingdezigns.com" + +# --- Setup --- +mkdir -p "$LOG_DIR" "$TMP_DIR" +START_TIME=$(date +%s) + +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] PLEX32 backup started" + +# --- Check NFS mount --- +if ! mountpoint -q "$NFS_DEST"; then + echo "[ERROR] NFS mount not available: $NFS_DEST" + exit 1 +fi + +# --- Stop containers --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Stopping Plex and Tautulli containers..." +cd "$COMPOSE_DIR" && docker compose down +STOP_STATUS=$? +if [ $STOP_STATUS -ne 0 ]; then + echo "[ERROR] Failed to stop containers cleanly" +fi + +# --- Tar Plex config --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating Plex config archive..." +PLEX_START=$(date +%s) +tar -czf "$TMP_DIR/$PLEX_ARCHIVE" \ + --exclude="config/Library/Application Support/Plex Media Server/Cache" \ + --exclude="config/Library/Application Support/Plex Media Server/Logs" \ + --exclude="config/Library/Application Support/Plex Media Server/Crash Reports" \ + -C /opt/plex config 2>&1 +PLEX_TAR_STATUS=$? +PLEX_END=$(date +%s) +PLEX_DURATION=$((PLEX_END - PLEX_START)) + +if [ $PLEX_TAR_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Plex archive created in ${PLEX_DURATION}s" +else + echo "[ERROR] Plex tar failed with status $PLEX_TAR_STATUS" +fi + +# --- Tar Tautulli config --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating Tautulli config archive..." +TAUTULLI_START=$(date +%s) +tar -czf "$TMP_DIR/$TAUTULLI_ARCHIVE" \ + -C /opt/tautulli config 2>&1 +TAUTULLI_TAR_STATUS=$? +TAUTULLI_END=$(date +%s) +TAUTULLI_DURATION=$((TAUTULLI_END - TAUTULLI_START)) + +if [ $TAUTULLI_TAR_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tautulli archive created in ${TAUTULLI_DURATION}s" +else + echo "[ERROR] Tautulli tar failed with status $TAUTULLI_TAR_STATUS" +fi + +# --- Restart containers --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restarting containers..." +cd "$COMPOSE_DIR" && docker compose up -d +START_STATUS=$? +if [ $START_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Containers restarted successfully" +else + echo "[ERROR] Failed to restart containers — manual intervention required" +fi + +# --- Copy Plex archive to NAS08 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Copying Plex archive to NAS08..." +COPY_PLEX_START=$(date +%s) +cp "$TMP_DIR/$PLEX_ARCHIVE" "$NFS_DEST/$PLEX_LOC/" +PLEX_COPY_STATUS=$? +COPY_PLEX_END=$(date +%s) +PLEX_COPY_DURATION=$((COPY_PLEX_END - COPY_PLEX_START)) + +if [ $PLEX_COPY_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Plex archive copied in ${PLEX_COPY_DURATION}s" +else + echo "[ERROR] Plex archive copy failed" +fi + +# --- Copy Tautulli archive to NAS08 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Copying Tautulli archive to NAS08..." +COPY_TAUTULLI_START=$(date +%s) +cp "$TMP_DIR/$TAUTULLI_ARCHIVE" "$NFS_DEST/$TAUTULLI_LOC/" +TAUTULLI_COPY_STATUS=$? +COPY_TAUTULLI_END=$(date +%s) +TAUTULLI_COPY_DURATION=$((COPY_TAUTULLI_END - COPY_TAUTULLI_START)) + +if [ $TAUTULLI_COPY_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tautulli archive copied in ${TAUTULLI_COPY_DURATION}s" +else + echo "[ERROR] Tautulli archive copy failed" +fi + +# --- Rotate old backups — keep last 3 --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Rotating old backups..." +ls -t "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | tail -n +$((RETENTION_COUNT + 1)) | xargs rm -f +ls -t "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | tail -n +$((RETENTION_COUNT + 1)) | xargs rm -f + +# --- Cleanup temp files --- +rm -f "$TMP_DIR/$PLEX_ARCHIVE" "$TMP_DIR/$TAUTULLI_ARCHIVE" + +# --- Sizes --- +PLEX_ARCHIVE_SIZE=$(ls -lh "$NFS_DEST/$PLEX_LOC/$PLEX_ARCHIVE" 2>/dev/null | awk '{print $5}') +TAUTULLI_ARCHIVE_SIZE=$(ls -lh "$NFS_DEST/$TAUTULLI_LOC/$TAUTULLI_ARCHIVE" 2>/dev/null | awk '{print $5}') +NFS_FREE=$(df -h "$NFS_DEST" 2>/dev/null | awk 'NR==2{print $4}') +PLEX_BACKUP_COUNT=$(ls "$NFS_DEST/$PLEX_LOC"/plex-config-*.tar.gz 2>/dev/null | wc -l) +TAUTULLI_BACKUP_COUNT=$(ls "$NFS_DEST/$TAUTULLI_LOC"/tautulli-config-*.tar.gz 2>/dev/null | wc -l) + +# --- Total duration --- +END_TIME=$(date +%s) +TOTAL_DURATION=$((END_TIME - START_TIME)) +TOTAL_MIN=$((TOTAL_DURATION / 60)) +TOTAL_SEC=$((TOTAL_DURATION % 60)) + +# --- Overall status --- +PLEX_STATUS=$((PLEX_TAR_STATUS + PLEX_COPY_STATUS)) +TAUTULLI_STATUS=$((TAUTULLI_TAR_STATUS + TAUTULLI_COPY_STATUS)) + +if [ $PLEX_STATUS -eq 0 ] && [ $TAUTULLI_STATUS -eq 0 ] && [ $START_STATUS -eq 0 ]; then + OVERALL_STATUS="SUCCESS" + STATUS_COLOR="#1a7f4b" + BANNER_ICON="✅" + BANNER_MSG="Backup completed successfully — Plex and Tautulli configs archived to NAS08" + BADGE_COLOR="#1a7f4b" +elif [ $PLEX_STATUS -ne 0 ] && [ $TAUTULLI_STATUS -ne 0 ]; then + OVERALL_STATUS="FAILED" + STATUS_COLOR="#b91c1c" + BANNER_ICON="🔴" + BANNER_MSG="Backup failed — both archive operations encountered errors" + BADGE_COLOR="#b91c1c" +else + OVERALL_STATUS="PARTIAL" + STATUS_COLOR="#b45309" + BANNER_ICON="⚠️" + BANNER_MSG="Backup partially completed — review individual results below" + BADGE_COLOR="#b45309" +fi + +# --- Status labels --- +if [ $PLEX_STATUS -eq 0 ]; then + PLEX_LABEL="✅ SUCCESS" + PLEX_ROW_BG="#f0fdf4" + PLEX_TEXT_COLOR="#166534" +else + PLEX_LABEL="❌ FAILED" + PLEX_ROW_BG="#fff1f2" + PLEX_TEXT_COLOR="#b91c1c" +fi + +if [ $TAUTULLI_STATUS -eq 0 ]; then + TAUTULLI_LABEL="✅ SUCCESS" + TAUTULLI_ROW_BG="#f0fdf4" + TAUTULLI_TEXT_COLOR="#166534" +else + TAUTULLI_LABEL="❌ FAILED" + TAUTULLI_ROW_BG="#fff1f2" + TAUTULLI_TEXT_COLOR="#b91c1c" +fi + +if [ $START_STATUS -eq 0 ]; then + RESTART_LABEL="✅ RUNNING" + RESTART_COLOR="#166534" +else + RESTART_LABEL="❌ CHECK REQUIRED" + RESTART_COLOR="#b91c1c" +fi + +# --- Send email --- +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Sending email report..." + +python3 << PYEOF +import smtplib, ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +smtp_server = "$SMTP_SERVER" +smtp_port = $SMTP_PORT +smtp_user = "$SMTP_USER" +smtp_pass = "$SMTP_PASS" +email_to = "$EMAIL_TO" +email_from = "$EMAIL_FROM" + +subject = "PLEX32 Backup — $OVERALL_STATUS — $DATESTAMP" + +html = """ + + + + + +
+ + + + + + + + + + + + + + +
+ + + +
+
Config Backup
+
🎬 plex32
+
$TIMESTAMP  |  VLAN 50 — Lab/Servers
+
+
$OVERALL_STATUS
+
+
+ $BANNER_ICON  $BANNER_MSG +
+ + + + + + + + + +
+
+
Duration
+
${TOTAL_MIN}m ${TOTAL_SEC}s
+
+
+
+
Plex Archive
+
$PLEX_ARCHIVE_SIZE
+
+
+
+
Tautulli Archive
+
$TAUTULLI_ARCHIVE_SIZE
+
+
+
+
NAS08 Free
+
$NFS_FREE
+
+
+ + +
+
📋 Backup Results
+ + + + + + + + + + + + + + + + + + + + + + +
ComponentArchiveCopies KeptDurationStatus
🎬 Plex Config$PLEX_ARCHIVE$PLEX_BACKUP_COUNT / $RETENTION_COUNT${PLEX_DURATION}s tar + ${PLEX_COPY_DURATION}s copy$PLEX_LABEL
📊 Tautulli Config$TAUTULLI_ARCHIVE$TAUTULLI_BACKUP_COUNT / $RETENTION_COUNT${TAUTULLI_DURATION}s tar + ${TAUTULLI_COPY_DURATION}s copy$TAUTULLI_LABEL
+
+ + +
+ Container Status After Backup + $RESTART_LABEL +
+ + +
+ Backup Destination + NAS08 (192.168.150.35) via NFS — $NFS_FREE free +
+ + +
+ 💡 To Restore + tar -xzf $PLEX_ARCHIVE -C /opt/plex +
+ +
+ Config Backup  |  plex32  |  KingDezigns Infrastructure +
+
+ + +""" + +msg = MIMEMultipart("alternative") +msg["Subject"] = subject +msg["From"] = email_from +msg["To"] = email_to +msg.attach(MIMEText(html, "html")) + +ctx = ssl.create_default_context() +with smtplib.SMTP_SSL(smtp_server, smtp_port, context=ctx, timeout=15) as s: + s.login(smtp_user, smtp_pass) + s.sendmail(email_from, [email_to], msg.as_string()) +print("Email sent successfully") +PYEOF + +EMAIL_STATUS=$? +if [ $EMAIL_STATUS -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Email sent successfully" +else + echo "[ERROR] Email send failed" +fi + +# --- Cleanup old logs --- +find "$LOG_DIR" -name "backup-*.log" -mtime +$RETENTION_DAYS -delete 2>/dev/null + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup complete — Status: $OVERALL_STATUS | Duration: ${TOTAL_MIN}m ${TOTAL_SEC}s" diff --git a/plex32_health_report.sh b/plex32_health_report.sh new file mode 100644 index 0000000..856282b --- /dev/null +++ b/plex32_health_report.sh @@ -0,0 +1,598 @@ +#!/bin/bash +# ============================================================= +# PLEX32 — Daily Server Health Report +# KingDezigns Infrastructure +# Schedule: Daily at 7:05 AM (run after update check) +# Location: /usr/scripts/plex32/plex32_health_report.sh +# ============================================================= + +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT="465" +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS="Valerie8545" +EMAIL_TO="rufus.king@kingdezigns.com" +EMAIL_FROM="rufus.king@kingdezigns.com" +TIMESTAMP=$(date "+%A, %B %d, %Y at %I:%M %p") +DATE_SHORT=$(date "+%Y-%m-%d") +LOG_DIR="/var/log/plex32-health" +LOG_FILE="$LOG_DIR/health-$DATE_SHORT.log" + +mkdir -p "$LOG_DIR" +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "[$TIMESTAMP] Starting health check..." + +# ============================================================= +# DISK USAGE — eMMC + NFS MOUNTS +# ============================================================= +DISK_WARN=85 +DISK_CRIT=95 +DISK_ISSUES="" +DISK_STATUS="OK" + +check_disk() { + local label="$1" + local mount="$2" + local pct=$(df -h "$mount" 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5); print $5}') + local size=$(df -h "$mount" 2>/dev/null | awk 'NR==2{print $2}') + local used=$(df -h "$mount" 2>/dev/null | awk 'NR==2{print $3}') + local avail=$(df -h "$mount" 2>/dev/null | awk 'NR==2{print $4}') + + if [ -z "$pct" ]; then + echo "UNAVAILABLE|$label|--|--|--|--" + return + fi + + local status="OK" + if [ "$pct" -ge "$DISK_CRIT" ]; then + status="CRITICAL" + elif [ "$pct" -ge "$DISK_WARN" ]; then + status="WARNING" + fi + echo "$status|$label|$size|$used|$avail|$pct%" +} + +ROOT_DISK=$(check_disk "eMMC — OS (/)." "/") +BOOT_DISK=$(check_disk "eMMC — /boot" "/boot") +NFS_CONFIG=$(check_disk "NAS08 — Plex Config" "/mnt/plex/config") +NFS_MEDIA=$(check_disk "NAS08 — Media" "/mnt/plex/media") + +for disk in "$ROOT_DISK" "$BOOT_DISK" "$NFS_CONFIG" "$NFS_MEDIA"; do + status=$(echo "$disk" | cut -d'|' -f1) + label=$(echo "$disk" | cut -d'|' -f2) + if [ "$status" = "CRITICAL" ]; then + DISK_STATUS="CRITICAL" + DISK_ISSUES+="CRITICAL: $label is nearly full."$'\n' + elif [ "$status" = "WARNING" ] && [ "$DISK_STATUS" != "CRITICAL" ]; then + DISK_STATUS="WARNING" + DISK_ISSUES+="WARNING: $label is getting full."$'\n' + elif [ "$status" = "UNAVAILABLE" ]; then + [ "$DISK_STATUS" = "OK" ] && DISK_STATUS="WARNING" + DISK_ISSUES+="WARNING: $label is unavailable."$'\n' + fi +done + +echo "[INFO] Disk status: $DISK_STATUS" + +# ============================================================= +# NFS MOUNT HEALTH +# ============================================================= +NFS_STATUS="OK" +NFS_ISSUES="" + +check_nfs() { + local label="$1" + local mount="$2" + if mountpoint -q "$mount" 2>/dev/null && timeout 5 ls "$mount" > /dev/null 2>&1; then + echo "OK|$label" + else + echo "DOWN|$label" + fi +} + +NFS_CONFIG_STATUS=$(check_nfs "Plex Config" "/mnt/plex/config") +NFS_TRANSCODE_STATUS=$(check_nfs "Plex Transcode" "/mnt/plex/transcode") +NFS_MEDIA_STATUS=$(check_nfs "Media Library" "/mnt/plex/media") + +for nfs in "$NFS_CONFIG_STATUS" "$NFS_TRANSCODE_STATUS" "$NFS_MEDIA_STATUS"; do + status=$(echo "$nfs" | cut -d'|' -f1) + label=$(echo "$nfs" | cut -d'|' -f2) + if [ "$status" = "DOWN" ]; then + NFS_STATUS="CRITICAL" + NFS_ISSUES+="CRITICAL: NFS mount $label is down."$'\n' + fi +done + +echo "[INFO] NFS status: $NFS_STATUS" + +# ============================================================= +# MEMORY +# ============================================================= +MEM_TOTAL=$(free -m | awk 'NR==2{print $2}') +MEM_USED=$(free -m | awk 'NR==2{print $3}') +MEM_AVAIL=$(free -m | awk 'NR==2{print $7}') +MEM_PCT=$(awk "BEGIN{printf \"%d\", ($MEM_USED/$MEM_TOTAL)*100}") +MEM_TOTAL_H=$(free -h | awk 'NR==2{print $2}') +MEM_USED_H=$(free -h | awk 'NR==2{print $3}') +MEM_AVAIL_H=$(free -h | awk 'NR==2{print $7}') + +MEM_STATUS="OK" +MEM_ISSUES="" +if [ "$MEM_PCT" -ge 95 ]; then + MEM_STATUS="CRITICAL" + MEM_ISSUES="CRITICAL: Memory usage at ${MEM_PCT}% — system may be under pressure." +elif [ "$MEM_PCT" -ge 85 ]; then + MEM_STATUS="WARNING" + MEM_ISSUES="WARNING: Memory usage at ${MEM_PCT}%." +fi + +echo "[INFO] Memory: ${MEM_PCT}% used" + +# ============================================================= +# CPU LOAD +# ============================================================= +LOAD_1=$(cat /proc/loadavg | awk '{print $1}') +LOAD_5=$(cat /proc/loadavg | awk '{print $2}') +LOAD_15=$(cat /proc/loadavg | awk '{print $3}') +CPU_CORES=$(nproc) +LOAD_PCT=$(awk "BEGIN{printf \"%d\", ($LOAD_5/$CPU_CORES)*100}") + +CPU_STATUS="OK" +CPU_ISSUES="" +if [ "$LOAD_PCT" -ge 90 ]; then + CPU_STATUS="CRITICAL" + CPU_ISSUES="CRITICAL: CPU load average is very high (${LOAD_5} on ${CPU_CORES} cores)." +elif [ "$LOAD_PCT" -ge 70 ]; then + CPU_STATUS="WARNING" + CPU_ISSUES="WARNING: CPU load average is elevated (${LOAD_5} on ${CPU_CORES} cores)." +fi + +echo "[INFO] CPU load: $LOAD_5 (5min avg), $CPU_CORES cores" + +# ============================================================= +# UPTIME & REBOOT CHECK +# ============================================================= +UPTIME_SECONDS=$(awk '{print int($1)}' /proc/uptime) +UPTIME_DAYS=$((UPTIME_SECONDS / 86400)) +UPTIME_HOURS=$(( (UPTIME_SECONDS % 86400) / 3600 )) +UPTIME_MINS=$(( (UPTIME_SECONDS % 3600) / 60 )) +UPTIME_DISPLAY="${UPTIME_DAYS}d ${UPTIME_HOURS}h ${UPTIME_MINS}m" + +REBOOT_STATUS="OK" +REBOOT_ISSUES="" +if [ -f /var/run/reboot-required ]; then + REBOOT_STATUS="WARNING" + REBOOT_ISSUES="Reboot required to apply kernel or system updates." +fi + +echo "[INFO] Uptime: $UPTIME_DISPLAY | Reboot required: $REBOOT_STATUS" + +# ============================================================= +# PLEX CONTAINER STATUS +# ============================================================= +PLEX_CONTAINER_STATUS="OK" +PLEX_CONTAINER_ISSUES="" +PLEX_RUNNING=$(docker ps --filter "name=plex" --filter "status=running" --format "{{.Names}}" 2>/dev/null) +PLEX_UPTIME=$(docker ps --filter "name=plex" --format "{{.Status}}" 2>/dev/null) + +if [ -z "$PLEX_RUNNING" ]; then + PLEX_CONTAINER_STATUS="CRITICAL" + PLEX_CONTAINER_ISSUES="CRITICAL: Plex container is not running." +fi + +echo "[INFO] Plex container: $PLEX_CONTAINER_STATUS — $PLEX_UPTIME" + +# ============================================================= +# FAILED SYSTEMD UNITS +# ============================================================= +SYSTEMD_STATUS="OK" +SYSTEMD_ISSUES="" +FAILED_UNITS=$(systemctl --failed --no-pager --plain 2>/dev/null | grep "●" | awk '{print $2}') + +if [ -n "$FAILED_UNITS" ]; then + SYSTEMD_STATUS="WARNING" + SYSTEMD_ISSUES="Failed units: $FAILED_UNITS" +fi + +echo "[INFO] Systemd failed units: $([ -z "$FAILED_UNITS" ] && echo 'none' || echo "$FAILED_UNITS")" + +# ============================================================= +# RECENT JOURNAL ERRORS (last 24h, filter known-harmless) +# ============================================================= +JOURNAL_ERRORS=$(journalctl -p err -b --no-pager 2>/dev/null \ + | grep -v "SGX disabled by BIOS" \ + | grep -v "^-- " \ + | tail -10) + +JOURNAL_COUNT=$(echo "$JOURNAL_ERRORS" | grep -c "." || true) +[ "$JOURNAL_COUNT" -le 1 ] && JOURNAL_COUNT=0 + +JOURNAL_STATUS="OK" +[ "$JOURNAL_COUNT" -gt 0 ] && JOURNAL_STATUS="WARNING" + +echo "[INFO] Journal errors: $JOURNAL_COUNT" + +# ============================================================= +# FAIL2BAN STATUS +# ============================================================= +F2B_RUNNING=$(systemctl is-active fail2ban 2>/dev/null) +F2B_BANS=$(fail2ban-client status sshd 2>/dev/null | grep "Total banned" | awk '{print $NF}' || echo "0") +F2B_CURRENTLY_BANNED=$(fail2ban-client status sshd 2>/dev/null | grep "Currently banned" | awk '{print $NF}' || echo "0") + +F2B_HEALTH_STATUS="OK" +F2B_HEALTH_ISSUES="" +if [ "$F2B_RUNNING" != "active" ]; then + F2B_HEALTH_STATUS="CRITICAL" + F2B_HEALTH_ISSUES="CRITICAL: Fail2Ban is not running." +fi + +echo "[INFO] Fail2Ban: $F2B_RUNNING | Total bans: $F2B_BANS | Current: $F2B_CURRENTLY_BANNED" + +# ============================================================= +# OVERALL STATUS +# ============================================================= +OVERALL="OK" +for s in "$DISK_STATUS" "$NFS_STATUS" "$MEM_STATUS" "$CPU_STATUS" "$REBOOT_STATUS" "$PLEX_CONTAINER_STATUS" "$SYSTEMD_STATUS" "$F2B_HEALTH_STATUS"; do + if [ "$s" = "CRITICAL" ]; then OVERALL="CRITICAL"; break; fi + if [ "$s" = "WARNING" ] && [ "$OVERALL" != "CRITICAL" ]; then OVERALL="WARNING"; fi +done + +if [ "$OVERALL" = "CRITICAL" ]; then + STATUS_COLOR="#b91c1c" + STATUS_BADGE="🚨 ACTION REQUIRED" + STATUS_BANNER_MSG="🚨  One or more critical issues detected — immediate attention required" + SUBJECT="🚨 PLEX32 — Critical Health Alert — $DATE_SHORT" +elif [ "$OVERALL" = "WARNING" ]; then + STATUS_COLOR="#b45309" + STATUS_BADGE="⚠ ATTENTION NEEDED" + STATUS_BANNER_MSG="⚠  One or more items need your attention" + SUBJECT="⚠ PLEX32 — Health Warning — $DATE_SHORT" +else + STATUS_COLOR="#1a7f4b" + STATUS_BADGE="✅ HEALTHY" + STATUS_BANNER_MSG="✅  All systems are healthy — no action required" + SUBJECT="✅ PLEX32 — Daily Health Report — $DATE_SHORT" +fi + +echo "[INFO] Overall status: $OVERALL" + +# ============================================================= +# BUILD DISK TABLE HTML +# ============================================================= +build_disk_row() { + local row="$1" + local status=$(echo "$row" | cut -d'|' -f1) + local label=$(echo "$row" | cut -d'|' -f2) + local size=$(echo "$row" | cut -d'|' -f3) + local used=$(echo "$row" | cut -d'|' -f4) + local avail=$(echo "$row" | cut -d'|' -f5) + local pct=$(echo "$row" | cut -d'|' -f6) + + local icon="✅" + local row_bg="white" + local pct_color="#1a7f4b" + [ "$status" = "WARNING" ] && icon="⚠" && row_bg="#fffbeb" && pct_color="#b45309" + [ "$status" = "CRITICAL" ] && icon="🚨" && row_bg="#fff1f2" && pct_color="#b91c1c" + [ "$status" = "UNAVAILABLE" ] && icon="❌" && row_bg="#fff1f2" && pct_color="#b91c1c" + + echo " + $icon $label + $size + $used + $avail + $pct + " +} + +DISK_ROWS="" +DISK_ROWS+=$(build_disk_row "$ROOT_DISK") +DISK_ROWS+=$(build_disk_row "$BOOT_DISK") +DISK_ROWS+=$(build_disk_row "$NFS_CONFIG") +DISK_ROWS+=$(build_disk_row "$NFS_MEDIA") + +# ============================================================= +# BUILD NFS TABLE HTML +# ============================================================= +build_nfs_row() { + local row="$1" + local status=$(echo "$row" | cut -d'|' -f1) + local label=$(echo "$row" | cut -d'|' -f2) + local icon="✅" + local row_bg="white" + local status_text="Mounted & Accessible" + local status_color="#1a7f4b" + if [ "$status" = "DOWN" ]; then + icon="🚨" + row_bg="#fff1f2" + status_text="DOWN — Not accessible" + status_color="#b91c1c" + fi + echo " + $icon $label + $status_text + " +} + +NFS_ROWS="" +NFS_ROWS+=$(build_nfs_row "$NFS_CONFIG_STATUS") +NFS_ROWS+=$(build_nfs_row "$NFS_TRANSCODE_STATUS") +NFS_ROWS+=$(build_nfs_row "$NFS_MEDIA_STATUS") + +# ============================================================= +# ISSUES SUMMARY HTML +# ============================================================= +ALL_ISSUES="" +[ -n "$DISK_ISSUES" ] && ALL_ISSUES+="$DISK_ISSUES" +[ -n "$NFS_ISSUES" ] && ALL_ISSUES+="$NFS_ISSUES" +[ -n "$MEM_ISSUES" ] && ALL_ISSUES+="$MEM_ISSUES"$'\n' +[ -n "$CPU_ISSUES" ] && ALL_ISSUES+="$CPU_ISSUES"$'\n' +[ -n "$REBOOT_ISSUES" ] && ALL_ISSUES+="$REBOOT_ISSUES"$'\n' +[ -n "$PLEX_CONTAINER_ISSUES" ] && ALL_ISSUES+="$PLEX_CONTAINER_ISSUES"$'\n' +[ -n "$SYSTEMD_ISSUES" ] && ALL_ISSUES+="$SYSTEMD_ISSUES"$'\n' +[ -n "$F2B_HEALTH_ISSUES" ] && ALL_ISSUES+="$F2B_HEALTH_ISSUES"$'\n' + +ISSUES_HTML="" +if [ -n "$ALL_ISSUES" ]; then + ISSUES_HTML="
+
⚠ Issues Detected
" + while IFS= read -r issue; do + [ -z "$issue" ] && continue + BG="#fffbeb"; BORDER="#b45309"; COL="#92400e" + echo "$issue" | grep -q "CRITICAL" && BG="#fff1f2" && BORDER="#b91c1c" && COL="#7f1d1d" + ISSUES_HTML+="
$issue
" + done <<< "$ALL_ISSUES" + ISSUES_HTML+="
" +fi + +# ============================================================= +# REBOOT REQUIRED HTML +# ============================================================= +REBOOT_HTML="" +if [ "$REBOOT_STATUS" = "WARNING" ]; then + REBOOT_HTML="
+
⚠ Reboot Required
+
A system reboot is required to complete pending updates.
+
sudo reboot
+
👆 Click command to select — then Ctrl+C
+
" +fi + +# ============================================================= +# JOURNAL ERRORS HTML +# ============================================================= +JOURNAL_HTML="" +if [ "$JOURNAL_COUNT" -gt 0 ]; then + JOURNAL_ESCAPED=$(echo "$JOURNAL_ERRORS" | sed 's/&/\&/g; s//\>/g') + JOURNAL_HTML="
+
📋 Recent System Errors
+
$JOURNAL_ESCAPED
+
" +fi + +# ============================================================= +# STATUS ICON HELPER +# ============================================================= +status_icon() { + case "$1" in + OK) echo "✅" ;; + WARNING) echo "⚠" ;; + CRITICAL) echo "🚨" ;; + *) echo "❓" ;; + esac +} + +STATUS_ICON_DISK=$(status_icon "$DISK_STATUS") +STATUS_ICON_NFS=$(status_icon "$NFS_STATUS") +STATUS_ICON_MEM=$(status_icon "$MEM_STATUS") +STATUS_ICON_CPU=$(status_icon "$CPU_STATUS") +STATUS_ICON_REBOOT=$(status_icon "$REBOOT_STATUS") +STATUS_ICON_PLEX=$(status_icon "$PLEX_CONTAINER_STATUS") +STATUS_ICON_SYSTEMD=$(status_icon "$SYSTEMD_STATUS") +STATUS_ICON_F2B=$(status_icon "$F2B_HEALTH_STATUS") + +# ============================================================= +# BUILD & SEND EMAIL +# ============================================================= +python3 << PYEOF +import smtplib, ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +timestamp = "$TIMESTAMP" +status_color = "$STATUS_COLOR" +status_badge = "$STATUS_BADGE" +status_banner = "$STATUS_BANNER_MSG" +subject = "$SUBJECT" +from_email = "$EMAIL_FROM" + +uptime_display = "$UPTIME_DISPLAY" +load_1 = "$LOAD_1" +load_5 = "$LOAD_5" +load_15 = "$LOAD_15" +cpu_cores = "$CPU_CORES" +mem_total = "$MEM_TOTAL_H" +mem_used = "$MEM_USED_H" +mem_avail = "$MEM_AVAIL_H" +mem_pct = "$MEM_PCT" +plex_uptime = "$PLEX_UPTIME" + +f2b_running = "$F2B_RUNNING" +f2b_bans = "$F2B_BANS" +f2b_current = "$F2B_CURRENTLY_BANNED" + +disk_rows = """$DISK_ROWS""" +nfs_rows = """$NFS_ROWS""" +issues_html = """$ISSUES_HTML""" +reboot_html = """$REBOOT_HTML""" +journal_html = """$JOURNAL_HTML""" + +icon_disk = "$STATUS_ICON_DISK" +icon_nfs = "$STATUS_ICON_NFS" +icon_mem = "$STATUS_ICON_MEM" +icon_cpu = "$STATUS_ICON_CPU" +icon_reboot = "$STATUS_ICON_REBOOT" +icon_plex = "$STATUS_ICON_PLEX" +icon_systemd = "$STATUS_ICON_SYSTEMD" +icon_f2b = "$STATUS_ICON_F2B" + +mem_color = "#b91c1c" if int(mem_pct) >= 95 else "#b45309" if int(mem_pct) >= 85 else "#1a7f4b" +load_color = "#b91c1c" if float(load_5) >= float(cpu_cores)*0.9 else "#b45309" if float(load_5) >= float(cpu_cores)*0.7 else "#1a7f4b" + +html = f""" + + + + +
+ + + + + + + + + + +
+ + + +
+
Health Report
+
🖥 PLEX32
+
{timestamp} | VLAN 50 — 192.168.150.45
+
+
{status_badge}
+
+
+ {status_banner} +
+ + + + + + + + + +
+
+
Uptime
+
{uptime_display}
+
+
+
+
CPU Load (5m)
+
{load_5}
+
+
+
+
Memory Used
+
{mem_pct}%
+
+
+
+
F2B Bans Today
+
{f2b_bans}
+
+
+ + {issues_html} + {reboot_html} + + +
+
🔍 System Overview
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SystemStatusDetail
{icon_plex} Plex ContainerRunning{plex_uptime}
{icon_f2b} Fail2Ban{f2b_running}Currently banned: {f2b_current}
{icon_mem} Memory{mem_used} / {mem_total} used{mem_avail} available
{icon_cpu} CPU Load{load_1} / {load_5} / {load_15}{cpu_cores} cores (1m / 5m / 15m avg)
{icon_systemd} Systemd UnitsNo failed unitsAll services healthy
{icon_reboot} Reboot Required{'⚠ Yes' if '$REBOOT_STATUS' == 'WARNING' else 'No'}{'Pending kernel/system updates' if '$REBOOT_STATUS' == 'WARNING' else 'System is current'}
+
+ + +
+
💾 Disk Usage
+ + + + + + + + + {disk_rows} +
FilesystemSizeUsedAvailUse%
+
+ + +
+
📁 NFS Mounts (NAS08)
+ + + + + + {nfs_rows} +
MountStatus
+
+ + {journal_html} + +
+ Health Report  |  PLEX32  |  KingDezigns Infrastructure +
+
+""" + +msg = MIMEMultipart("alternative") +msg["Subject"] = subject +msg["From"] = f"KingDezigns PLEX32 <{from_email}>" +msg["To"] = "$EMAIL_TO" +msg.attach(MIMEText(html, "html")) + +context = ssl.create_default_context() +try: + with smtplib.SMTP_SSL("$SMTP_SERVER", $SMTP_PORT, context=context, timeout=15) as server: + server.login("$SMTP_USER", "$SMTP_PASS") + server.sendmail("$EMAIL_FROM", "$EMAIL_TO", msg.as_string()) + print("[$TIMESTAMP] Health report email sent successfully.") +except Exception as e: + print(f"[$TIMESTAMP] Email send failed: {e}") + exit(1) +PYEOF + +echo "[$TIMESTAMP] Health check complete." diff --git a/plex32_launcher.sh b/plex32_launcher.sh new file mode 100644 index 0000000..78f1e37 --- /dev/null +++ b/plex32_launcher.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# ============================================================= +# PLEX32 Remote Launcher +# Run from KingDezigns001 to execute scripts on PLEX32 +# Location: ~/scripts/plex32_launcher.sh +# ============================================================= + +PLEX32_HOST="rufusking@192.168.150.45" + +clear +echo "=============================================" +echo " PLEX32 Remote Script Launcher" +echo " KingDezigns Home Network" +echo "=============================================" +echo "" +echo " Which script do you want to run?" +echo "" +echo " 1) Update Check" +echo " Checks for OS, Plex, Docker and Fail2Ban updates" +echo "" +echo " 2) Daily Health Report" +echo " Disk, NFS, memory, CPU, container and Fail2Ban status" +echo "" +echo " 3) Plex32 Backup" +echo " Archives Plex and Tautulli configs to NAS08" +echo " Note: Plex will be briefly offline during backup" +echo "" +echo " q) Quit" +echo "" +echo "=============================================" +read -rp " Enter choice [1-3 or q]: " CHOICE +echo "" + +case "$CHOICE" in + 1) + SCRIPT="/usr/scripts/plex32/plex32_update_check.sh" + LABEL="Update Check" + ;; + 2) + SCRIPT="/usr/scripts/plex32/plex32_health_report.sh" + LABEL="Health Report" + ;; + 3) + SCRIPT="/usr/scripts/plex32/plex32_backup.sh" + LABEL="Plex32 Backup" + echo " ⚠ Plex will be briefly stopped during backup." + read -rp " Continue? [y/N]: " CONFIRM + echo "" + if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then + echo " Cancelled." + echo "" + read -rp " Press Enter to close..." + exit 0 + fi + ;; + q|Q) + echo " Goodbye." + exit 0 + ;; + *) + echo " Invalid choice. Exiting." + echo "" + read -rp " Press Enter to close..." + exit 1 + ;; +esac + +echo "=============================================" +echo " Running: $LABEL on plex32" +echo " $(date '+%B %d, %Y at %I:%M %p')" +echo "=============================================" +echo "" + +ssh -t "$PLEX32_HOST" "bash $SCRIPT" +SSH_STATUS=$? + +echo "" +echo "=============================================" +if [ $SSH_STATUS -eq 0 ]; then + echo " ✅ $LABEL completed successfully" +else + echo " ⚠ $LABEL finished with status $SSH_STATUS" + echo " Check the output above for details" +fi +echo " $(date '+%B %d, %Y at %I:%M %p')" +echo "=============================================" +echo "" +read -rp " Press Enter to close this window..." diff --git a/plex32_reboot.sh b/plex32_reboot.sh new file mode 100644 index 0000000..6d4f682 --- /dev/null +++ b/plex32_reboot.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# ============================================================= +# plex32_reboot.sh — Remote reboot of PLEX32 from NAS08 +# Location: /usr/scripts/nas/plex32_reboot.sh +# Run via: OMV Scheduled Jobs (disabled — run manually) +# ============================================================= + +PLEX32_IP="192.168.150.45" +PLEX32_USER="rufusking" +SSH_KEY="/root/.ssh/id_ed25519" +LOG_DIR="/var/log/plex32-reboot" +LOG_FILE="$LOG_DIR/reboot-$(date +%Y%m%d-%H%M%S).log" + +mkdir -p "$LOG_DIR" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +# --- Clean up logs older than 90 days --- +find "$LOG_DIR" -name "reboot-*.log" -mtime +90 -delete + +log "========================================" +log "PLEX32 Remote Reboot — Initiated" +log "Target: $PLEX32_USER@$PLEX32_IP" +log "========================================" + +# --- Stop Plex and Tautulli containers gracefully before reboot --- +log "Stopping Docker containers on PLEX32..." +ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \ + "$PLEX32_USER@$PLEX32_IP" \ + "cd ~/docker/plex && docker compose down" >> "$LOG_FILE" 2>&1 + +if [ $? -eq 0 ]; then + log "Docker containers stopped cleanly." +else + log "WARNING: docker compose down failed or timed out — proceeding with reboot anyway." +fi + +# --- Issue reboot --- +log "Issuing reboot command to PLEX32..." +ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o BatchMode=yes \ + "$PLEX32_USER@$PLEX32_IP" \ + "sudo reboot" >> "$LOG_FILE" 2>&1 + +# SSH will return a non-zero exit code when the connection drops due to reboot — that's expected +log "Reboot command sent. PLEX32 is going down." +log "" + +# --- Wait for PLEX32 to come back online --- +log "Pinging Server -- Waiting for PLEX32 to come back online..." +WAIT_MAX=180 # seconds to wait total +WAIT_INTERVAL=10 +ELAPSED=0 +ONLINE=false + +sleep 30 # Give it time to actually go down before polling + +while [ $ELAPSED -lt $WAIT_MAX ]; do + if ping -c 1 -W 2 "$PLEX32_IP" > /dev/null 2>&1; then + log "PLEX32 is back online (ping responded after ~$((ELAPSED + 30))s)." + ONLINE=true + break + fi + sleep $WAIT_INTERVAL + ELAPSED=$((ELAPSED + WAIT_INTERVAL)) +done + +if [ "$ONLINE" = false ]; then + log "WARNING: PLEX32 did not respond to ping within $((WAIT_MAX + 30)) seconds." + log "It may still be booting, or there may be a problem. Check manually." + exit 1 +fi + +# --- Start Docker containers on PLEX32 --- +log "Starting Docker containers on PLEX32..." +ssh -i "$SSH_KEY" -o ConnectTimeout=15 -o BatchMode=yes \ + "$PLEX32_USER@$PLEX32_IP" \ + "cd ~/docker/plex && docker compose up -d" >> "$LOG_FILE" 2>&1 + +if [ $? -eq 0 ]; then + log "Docker containers started." +else + log "WARNING: docker compose up -d failed — check PLEX32 manually." +fi + +# --- Poll for Plex port 32400 to respond --- +log "Waiting for Plex (port 32400) to become available..." +PLEX_WAIT_MAX=90 +PLEX_ELAPSED=0 +PLEX_UP=false + +while [ $PLEX_ELAPSED -lt $PLEX_WAIT_MAX ]; do + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://$PLEX32_IP:32400/web") + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + log "Plex is responding (HTTP $HTTP_CODE) after ${PLEX_ELAPSED}s — reboot successful." + PLEX_UP=true + break + fi + sleep 5 + PLEX_ELAPSED=$((PLEX_ELAPSED + 5)) +done + +if [ "$PLEX_UP" = false ]; then + log "NOTE: Plex did not respond within ${PLEX_WAIT_MAX}s. Containers may still be starting." + log "Allow another minute then check http://$PLEX32_IP:32400/web manually." +fi + +log "" +log "========================================" +log "Reboot sequence complete." +log "Log saved to: $LOG_FILE" +log "========================================" + +exit 0 diff --git a/plex32_update_check.sh b/plex32_update_check.sh new file mode 100644 index 0000000..f947572 --- /dev/null +++ b/plex32_update_check.sh @@ -0,0 +1,383 @@ +#!/bin/bash +# ============================================================= +# PLEX32 — Daily Update Check & Notification +# KingDezigns Infrastructure +# Schedule: Daily at 7:00 AM +# Location: /usr/scripts/plex32/plex32_update_check.sh +# ============================================================= + +HOSTNAME="plex32" +SMTP_SERVER="smtppro.zoho.com" +SMTP_PORT="465" +SMTP_USER="rufus.king@kingdezigns.com" +SMTP_PASS="Valerie8545" +EMAIL_TO="rufus.king@kingdezigns.com" +EMAIL_FROM="rufus.king@kingdezigns.com" +TIMESTAMP=$(date "+%A, %B %d, %Y at %I:%M %p") +DATE_SHORT=$(date "+%Y-%m-%d") +LOG_DIR="/var/log/plex32-updates" +LOG_FILE="$LOG_DIR/update-check-$DATE_SHORT.log" + +mkdir -p "$LOG_DIR" +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "[$TIMESTAMP] Starting update check..." + +# ============================================================= +# STEP 1 — CHECK OS/APT UPDATES +# ============================================================= +echo "[INFO] Checking apt updates..." +apt_output=$(apt-get update -qq 2>&1) +apt_check_status=$? + +if [ $apt_check_status -ne 0 ]; then + APT_FAILED=true + APT_UPDATES=0 + APT_DETAILS="apt-get update failed:\n$apt_output" +else + APT_FAILED=false + apt_list=$(apt list --upgradable 2>/dev/null | grep -v "Listing..." | grep -v "^$") + APT_UPDATES=$(echo "$apt_list" | grep -c "/" || true) + APT_DETAILS="$apt_list" +fi + +echo "[INFO] APT updates available: $APT_UPDATES" + +# ============================================================= +# STEP 2 — CHECK PLEX DOCKER IMAGE UPDATE +# ============================================================= +echo "[INFO] Checking Plex Docker image..." +PLEX_FAILED=false +PLEX_UPDATE_AVAILABLE=false +PLEX_ERROR_HTML="" + +PLEX_PULL_OUTPUT=$(docker pull lscr.io/linuxserver/plex:latest 2>&1) +PLEX_PULL_STATUS=$? + +PLEX_IMAGE_VERSION=$(docker inspect lscr.io/linuxserver/plex:latest --format '{{index .Config.Labels "build_version"}}' 2>/dev/null || echo "unknown") +CURRENT_IMAGE_DATE=$(docker inspect --format='{{.Created}}' lscr.io/linuxserver/plex:latest 2>/dev/null | cut -d'T' -f1) + +if [ $PLEX_PULL_STATUS -ne 0 ]; then + PLEX_FAILED=true + PLEX_STATUS_TEXT="Check failed — see error detail below" + echo "[ERROR] docker pull exited $PLEX_PULL_STATUS. Full output:" + echo "$PLEX_PULL_OUTPUT" + PLEX_ERROR_HTML="
⚠ Docker Pull Error (exit $PLEX_PULL_STATUS)
$(echo "$PLEX_PULL_OUTPUT" | sed 's/&/\&/g; s//\>/g')
" +elif echo "$PLEX_PULL_OUTPUT" | grep -q "Pull complete"; then + PLEX_UPDATE_AVAILABLE=true + PLEX_STATUS_TEXT="Update available — new image pulled" +else + PLEX_STATUS_TEXT="Up to date" +fi + +echo "[INFO] Plex status: $PLEX_STATUS_TEXT" + +# ============================================================= +# STEP 3 — CHECK FAIL2BAN VERSION +# ============================================================= +echo "[INFO] Checking Fail2Ban..." +F2B_UPDATE_AVAILABLE=false + +F2B_CANDIDATE=$(apt-cache policy fail2ban 2>/dev/null | grep Candidate | awk '{print $2}' | cut -d'-' -f1) +F2B_INSTALLED=$(apt-cache policy fail2ban 2>/dev/null | grep Installed | awk '{print $2}' | cut -d'-' -f1) + +if [ "$F2B_CANDIDATE" != "$F2B_INSTALLED" ] && [ -n "$F2B_CANDIDATE" ]; then + F2B_UPDATE_AVAILABLE=true + F2B_STATUS_TEXT="Update available: $F2B_INSTALLED → $F2B_CANDIDATE" +else + F2B_STATUS_TEXT="Up to date ($F2B_INSTALLED)" +fi + +echo "[INFO] Fail2Ban status: $F2B_STATUS_TEXT" + +# ============================================================= +# STEP 4 — CHECK DOCKER ENGINE UPDATE +# ============================================================= +echo "[INFO] Checking Docker..." +DOCKER_UPDATE_AVAILABLE=false + +DOCKER_CANDIDATE=$(apt-cache policy docker-ce 2>/dev/null | grep Candidate | awk '{print $2}') +DOCKER_INSTALLED=$(apt-cache policy docker-ce 2>/dev/null | grep Installed | awk '{print $2}') + +if [ "$DOCKER_CANDIDATE" != "$DOCKER_INSTALLED" ] && [ -n "$DOCKER_CANDIDATE" ]; then + DOCKER_UPDATE_AVAILABLE=true + DOCKER_STATUS_TEXT="Update available: $DOCKER_INSTALLED → $DOCKER_CANDIDATE" +else + DOCKER_STATUS_TEXT="Up to date ($DOCKER_INSTALLED)" +fi + +echo "[INFO] Docker status: $DOCKER_STATUS_TEXT" + +# ============================================================= +# STEP 5 — DETERMINE OVERALL STATUS +# ============================================================= +ANY_FAILED=false +ANY_UPDATES=false + +[ "$APT_FAILED" = true ] && ANY_FAILED=true +[ "$PLEX_FAILED" = true ] && ANY_FAILED=true +[ "$APT_UPDATES" -gt 0 ] && ANY_UPDATES=true +[ "$PLEX_UPDATE_AVAILABLE" = true ] && ANY_UPDATES=true +[ "$F2B_UPDATE_AVAILABLE" = true ] && ANY_UPDATES=true +[ "$DOCKER_UPDATE_AVAILABLE" = true ] && ANY_UPDATES=true + +if [ "$ANY_FAILED" = true ]; then + STATUS_COLOR="#b45309" + STATUS_BADGE="⚠ CHECK FAILED" + STATUS_BANNER_MSG="⚠  One or more update checks failed — manual verification required" + SUBJECT="⚠ PLEX32 — Update Check Failed — $DATE_SHORT" +elif [ "$ANY_UPDATES" = true ]; then + STATUS_COLOR="#1d6fa8" + STATUS_BADGE="🔄 UPDATES AVAILABLE" + STATUS_BANNER_MSG="🔄  Updates are available for one or more components" + SUBJECT="🔄 PLEX32 — Updates Available — $DATE_SHORT" +else + STATUS_COLOR="#1a7f4b" + STATUS_BADGE="✅ ALL CURRENT" + STATUS_BANNER_MSG="✅  All components are up to date — no action required" + SUBJECT="✅ PLEX32 — All Systems Current — $DATE_SHORT" +fi + +# ============================================================= +# STEP 6 — BUILD COMMAND BLOCKS (click to select) +# ============================================================= +make_cmd_block() { + local step_num="$1" + local step_label="$2" + local cmd="$3" + local cmd_html=$(echo "$cmd" | sed 's/&/\&/g; s//\>/g') + echo "
+
$step_num. $step_label
+
👆 Click inside to select all — then Ctrl+C
+
$cmd_html
+
" +} + +UPDATE_COMMANDS_HTML="" +if [ "$ANY_UPDATES" = true ]; then + UPDATE_COMMANDS_HTML="
🛠 Commands to Apply Updates (run in order)
" + + STEP=1 + if [ "$APT_UPDATES" -gt 0 ]; then + CMD="sudo apt update && sudo apt upgrade -y" + UPDATE_COMMANDS_HTML+=$(make_cmd_block "$STEP" "OS & Package Updates" "$CMD") + STEP=$((STEP+1)) + fi + + if [ "$PLEX_UPDATE_AVAILABLE" = true ]; then + CMD="cd ~/docker/plex +docker compose up -d" + UPDATE_COMMANDS_HTML+=$(make_cmd_block "$STEP" "Plex Media Server (Docker)" "$CMD") + STEP=$((STEP+1)) + fi + + if [ "$DOCKER_UPDATE_AVAILABLE" = true ]; then + CMD="sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +docker --version" + UPDATE_COMMANDS_HTML+=$(make_cmd_block "$STEP" "Docker Engine" "$CMD") + STEP=$((STEP+1)) + fi + + if [ "$F2B_UPDATE_AVAILABLE" = true ]; then + CMD="sudo apt update && sudo apt install -y fail2ban +sudo systemctl restart fail2ban +sudo systemctl status fail2ban" + UPDATE_COMMANDS_HTML+=$(make_cmd_block "$STEP" "Fail2Ban" "$CMD") + fi + + UPDATE_COMMANDS_HTML+="
" +fi + +# ============================================================= +# STEP 7 — BUILD APT PACKAGES TABLE +# ============================================================= +APT_PACKAGES_HTML="" +if [ "$APT_UPDATES" -gt 0 ] && [ "$APT_FAILED" = false ]; then + APT_PACKAGES_HTML='
+
📦 Packages Awaiting Update
+ + + + + ' + + while IFS= read -r line; do + [ -z "$line" ] && continue + pkg=$(echo "$line" | awk '{print $1}' | cut -d'/' -f1) + ver=$(echo "$line" | awk '{print $2}') + APT_PACKAGES_HTML+="" + done <<< "$APT_DETAILS" + + APT_PACKAGES_HTML+='
PackageAvailable Version
$pkg$ver
' +fi + +# ============================================================= +# STEP 8 — BADGES +# ============================================================= +make_badge() { + local update="$1" + local failed="$2" + if [ "$failed" = true ]; then + echo "CHECK FAILED" + elif [ "$update" = true ]; then + echo "UPDATE AVAILABLE" + else + echo "CURRENT" + fi +} + +PLEX_BADGE=$(make_badge "$PLEX_UPDATE_AVAILABLE" "$PLEX_FAILED") +F2B_BADGE=$(make_badge "$F2B_UPDATE_AVAILABLE" "false") +DOCKER_BADGE=$(make_badge "$DOCKER_UPDATE_AVAILABLE" "false") + +# ============================================================= +# STEP 9 — BUILD & SEND EMAIL +# ============================================================= +python3 << PYEOF +import smtplib, ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +apt_updates = $APT_UPDATES +plex_update = "$PLEX_UPDATE_AVAILABLE" == "true" +f2b_update = "$F2B_UPDATE_AVAILABLE" == "true" +docker_update = "$DOCKER_UPDATE_AVAILABLE" == "true" +any_failed = "$ANY_FAILED" == "true" +status_color = "$STATUS_COLOR" +status_badge = "$STATUS_BADGE" +status_banner = "$STATUS_BANNER_MSG" +subject = "$SUBJECT" +plex_status = "$PLEX_STATUS_TEXT" +f2b_status = "$F2B_STATUS_TEXT" +docker_status = "$DOCKER_STATUS_TEXT" +plex_badge = """$PLEX_BADGE""" +f2b_badge = """$F2B_BADGE""" +docker_badge = """$DOCKER_BADGE""" +update_commands = """$UPDATE_COMMANDS_HTML""" +apt_packages_html = """$APT_PACKAGES_HTML""" +plex_error_html = """$PLEX_ERROR_HTML""" +timestamp = "$TIMESTAMP" +plex_image_ver = "$PLEX_IMAGE_VERSION" +current_image_date= "$CURRENT_IMAGE_DATE" +from_email = "$EMAIL_FROM" + +total_updates = apt_updates + (1 if plex_update else 0) + (1 if f2b_update else 0) + (1 if docker_update else 0) +apt_card_color = "#1d6fa8" if apt_updates > 0 else "#111827" +plex_card_color = "#1d6fa8" if plex_update else "#111827" +total_card_color = "#1d6fa8" if total_updates > 0 else "#1a7f4b" +other_update = f2b_update or docker_update +other_card_color = "#1d6fa8" if other_update else "#111827" +other_count = sum([1 if f2b_update else 0, 1 if docker_update else 0]) + +html = f""" + + + + +
+ + + + + + + + + + +
+ + + +
+
Update Monitor
+
🖥 PLEX32
+
{timestamp} | VLAN 50 — 192.168.150.45
+
+
{status_badge}
+
+
+ {status_banner} +
+ + + + + + + + +
+
+
Total Updates
+
{total_updates}
+
+
+
+
OS Packages
+
{apt_updates}
+
+
+
+
Plex
+
{'1' if plex_update else '0'}
+
+
+
+
Other
+
{other_count}
+
+
+ +
+
📋 Component Status
+ + + + + + + + + + + + + + + + + + + + + +
ComponentStatus
🐧 Ubuntu Server (apt){apt_updates} package(s) available
🎬 Plex Media Server {plex_badge}{plex_status}
Image: {plex_image_ver} | Built: {current_image_date}{plex_error_html}
🛡 Fail2Ban {f2b_badge}{f2b_status}
🐳 Docker Engine {docker_badge}{docker_status}
+
+ + {update_commands} + {apt_packages_html} + +
+ Update Monitor  |  PLEX32  |  KingDezigns Infrastructure +
+
+""" + +msg = MIMEMultipart("alternative") +msg["Subject"] = subject +msg["From"] = f"KingDezigns PLEX32 <{from_email}>" +msg["To"] = "$EMAIL_TO" +msg.attach(MIMEText(html, "html")) + +context = ssl.create_default_context() +try: + with smtplib.SMTP_SSL("$SMTP_SERVER", $SMTP_PORT, context=context, timeout=15) as server: + server.login("$SMTP_USER", "$SMTP_PASS") + server.sendmail("$EMAIL_FROM", "$EMAIL_TO", msg.as_string()) + print("[$TIMESTAMP] Email sent successfully.") +except Exception as e: + print(f"[$TIMESTAMP] Email send failed: {e}") + exit(1) +PYEOF + +echo "[$TIMESTAMP] Update check complete."