]> # Prepare plugin environment rm -rf /usr/local/emhttp/plugins/&name; mkdir -p /usr/local/emhttp/plugins/&name;/scripts mkdir -p &plugdir; echo echo "----------------------------------------------------" echo " &name; version &version; has been installed." echo " To configure, go to Settings > &name;" echo "----------------------------------------------------" echo ]]> 'MOVIE_SORT_ORDER', 'TV_SHOW_DIRS' => 'TV_SORT_ORDER', 'OTHER_DIRS' => 'OTHER_SORT_ORDER']; foreach ($mapping as $dir_key => $sort_key) { if (!empty($raw_cfg[$dir_key])) { $sort_val = $raw_cfg[$sort_key] ?? 'smallest'; if ($sort_val === 'fewest_seasons') $sort_val = 'smallest'; foreach (explode(',', $raw_cfg[$dir_key]) as $f) { $trimmed = trim($f); if (!empty($trimmed)) $migrated_paths[] = $trimmed . ':' . $sort_val . ':'; } } } if (!empty($migrated_paths)) { $raw_cfg['MANAGED_PATHS'] = implode('|', $migrated_paths); $content = ""; foreach ($raw_cfg as $k => $v) { $content .= "$k=\"$v\"\n"; } file_put_contents(CONFIG_FILE, $content); } } } if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['save_settings'])) { // We must forcefully prevent the rest of Unraid's UI wrapper from loading so that // the only output is our AJAX response ("success" or error) while(ob_get_level()) ob_end_clean(); $sys_var = @parse_ini_file('/var/local/emhttp/var.ini', false, INI_SCANNER_RAW) ?: []; // Look in var.ini first, then try the POSTed value which might be Unraid's new format $valid_token = $sys_var['csrf_token'] ?? $var['csrf_token'] ?? ''; $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; // Unraid 7.3 handles the CSRF validation mostly on the Nginx layer, // but if it makes it here we do a loose check. If the system CSRF token is not set, we bypass. if (!empty($valid_token) && !empty($token) && $token !== $valid_token) { // We will just log this to the system log, and allow the save if Unraid's wrapper let it through error_log("DiskSpaceManagement CSRF warning: Token mismatch. Received: " . substr($token,0,4) . "... Expected: " . substr($valid_token,0,4) . "..."); } try { if (!is_dir(CONFIG_PATH)) { if (!mkdir(CONFIG_PATH, 0770, true)) { if (!headers_sent()) header('HTTP/1.1 500 Internal Server Error'); echo "Failed to create config directory."; exit; } } $schedule = $_POST['CRON_SCHEDULE'] ?? '0 3 * * *'; $cron_enabled = $_POST['CRON_ENABLED'] ?? 'true'; $new_config = [ 'THRESHOLD_GB' => $_POST['THRESHOLD_GB'] ?? '50', 'DRY_RUN' => $_POST['DRY_RUN'] ?? 'true', 'CRON_ENABLED' => $cron_enabled, 'CRON_SCHEDULE' => $schedule, 'NOTIFY' => $_POST['NOTIFY'] ?? 'true', 'MANAGED_PATHS' => $_POST['MANAGED_PATHS'] ?? '', 'EXCLUDED_DISKS' => $_POST['EXCLUDED_DISKS'] ?? '', 'LOG_FILE' => $_POST['LOG_FILE'] ?? '/var/log/diskspacemanagement.log' ]; $content = ""; foreach ($new_config as $k => $v) { $content .= "$k=\"$v\"\n"; } if (file_put_contents(CONFIG_FILE, $content) === false) { if (!headers_sent()) header('HTTP/1.1 500 Internal Server Error'); echo "Failed to write to settings file."; exit; } $cron_file = "/boot/config/plugins/dynamix/" . PLUGIN_NAME . ".cron"; if ($cron_enabled === 'true') { $cron_content = "# Generated by DiskSpaceManagement\n$schedule /usr/local/emhttp/plugins/DiskSpaceManagement/scripts/disk_space_management.sh > /dev/null 2>&1\n"; file_put_contents($cron_file, $cron_content); } else { if (file_exists($cron_file)) unlink($cron_file); } exec("update_cron"); // Final clean and success output echo "DSM_SAVE_SUCCESS"; exit; } catch (Throwable $e) { if (!headers_sent()) header('HTTP/1.1 500 Internal Server Error'); echo "PHP Exception: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine(); exit; } } function get_config_val($key, $default = '') { if (!file_exists(CONFIG_FILE)) return htmlspecialchars($default); $config = parse_ini_file(CONFIG_FILE, false, INI_SCANNER_RAW); return htmlspecialchars($config[$key] ?? $default, ENT_QUOTES, 'UTF-8'); } function get_array_disks() { $disks = []; if (!file_exists('/var/local/emhttp/disks.ini')) return $disks; $ini = parse_ini_file('/var/local/emhttp/disks.ini', true); foreach ($ini as $key => $details) { if (strpos($key, 'disk') === 0 && !empty($details['device'])) { preg_match('/(\d+)/', $key, $m); $disks[] = ['num' => (int)$m[1], 'label' => $details['name'] ?? ucfirst($key), 'path' => '/mnt/'.$key, 'device' => $details['device']]; } } usort($disks, function($a, $b) { return $a['num'] <=> $b['num']; }); return $disks; } function render_dsm_ui() { global $var; $all_disks = get_array_disks(); $managed_paths_raw = get_config_val('MANAGED_PATHS', ''); $managed_items = []; if (!empty($managed_paths_raw)) { foreach (explode('|', $managed_paths_raw) as $item) { $parts = explode(':', $item); if (count($parts) >= 2) { $managed_items[] = ['path' => $parts[0], 'sort' => $parts[1], 'excl' => isset($parts[2]) ? explode(',', $parts[2]) : []]; } } } $is_dry_run = get_config_val('DRY_RUN', 'true') === 'true'; $is_running = file_exists('/tmp/dsm.running'); // Logic for UI rendering of Cron settings $cron_schedule_val = get_config_val('CRON_SCHEDULE', '0 3 * * *'); // DYNAMIC DEFAULT LOGIC: // If settings.cfg exists, default is 'true' (Upgrade scenario: preserve functionality) // If settings.cfg MISSING, default is 'false' (New Install scenario: start disabled) $default_cron_state = file_exists(CONFIG_FILE) ? 'true' : 'false'; $cron_enabled_val = get_config_val('CRON_ENABLED', $default_cron_state); // If user previously set it to disabled manually in the old version if ($cron_schedule_val === 'disabled') { $cron_enabled_val = 'false'; $cron_schedule_val = '0 3 * * *'; // Reset to default for the textbox } ?>

Disk Space Management v

RUNNING' : ' IDLE' ?> Settings Saved!
Help & Info

Threshold: Moves trigger when a disk falls below this free space value.

Mode: Dry Run = Will not move files, only simulated moves are logged. Live Mode = Will move files.

Global Excluded Disks: Selected disks are excluded from move operations entirely (will not touch).

Log File Path: Save the log on the array or a pool to make it persistent. Default path is wiped at restart.

Cron Schedule: Standard cron format for automatic execution. It's recommended to set the cron schedule to a time when you know the mover has finished. Example: '0 3 * * *' runs at 3:00 AM every day. See this link if you need help figuering out what to put here: crontab.guru

Notifications: Sends detailed summaries to the Unraid WebUI and notification agents.

Folder Priority Queue: Paths are processed top to bottom. Click anywhere on the row to drag and prioritize.

Per-Path Excluded Disks: Hover over "EXCL. DISKS" on a path to exclude a disk FROM moves on that path specifically.

Per-Path Sorting Order: The sorting order of which files are moved first.

Folder Priority Queue
X
Engine Rules
Automation
Execution Logs
Loading log output...
Changelog
About DSM

Disk Space Management intelligently balances Unraid array. This plugin was created mainly for those who use the split-level feature in Unraid. Due to how split level works, it will ignore the minimum free space setting and continue to move stuff to disks that are full/almost full. This is because split level tries to keep files and folders that belong together based on your split level setting on the same disk, and split level trumps all other settings. Even if there's little space left on the disk. So to combat this, this plugin will automatically move folders from disks that are below the threshold setting to the disk with the most free space available. You can customize the order of which folders gets moved first, and also the order to sort them to determine which folders get moved first. It's recommended to set the cron schedule to a time when you know the mover has finished.

]]>
]]> ]]> ]]> /dev/null 2>&1 &"); ?>]]> $file, 'path' => "$path/$file"]; } } header('Content-Type: application/json'); echo json_encode($dirs); ?>]]> "$RUN_FILE" trap "rm -f $RUN_FILE" EXIT # ============================================================================ # CONFIGURATION LOADING # ============================================================================ CONFIG_FILE="/boot/config/plugins/DiskSpaceManagement/settings.cfg" # Function: get_cfg # Description: Extracts a configuration value from the settings file # Parameters: $1 = configuration key name # Returns: The value associated with the key (content between quotes) get_cfg() { grep "^$1=" "$CONFIG_FILE" | cut -d'"' -f2; } # Load configuration values with defaults THRESHOLD_GB=$(get_cfg "THRESHOLD_GB") DRY_RUN=$(get_cfg "DRY_RUN") LOG_FILE=$(get_cfg "LOG_FILE") NOTIFY=$(get_cfg "NOTIFY") MANAGED_PATHS=$(get_cfg "MANAGED_PATHS") EXCLUDED_DISKS=$(get_cfg "EXCLUDED_DISKS") # Apply default values if configuration is missing THRESHOLD_GB=${THRESHOLD_GB:-50} DRY_RUN=${DRY_RUN:-true} LOG_FILE=${LOG_FILE:-/var/log/diskspacemanagement.log} NOTIFY=${NOTIFY:-true} # ============================================================================ # LOGGING SETUP # ============================================================================ # Create timestamped log that outputs to both file and a temp file for notifications SCRIPT_START_TIME=$(date +"%Y-%m-%d %H:%M:%S") TEMP_LOG_FILE=$(mktemp) trap 'rm -f "$TEMP_LOG_FILE"; rm -f "$RUN_FILE"' EXIT exec > >(tee -a "$TEMP_LOG_FILE" >> "$LOG_FILE") 2>&1 # ============================================================================ # STATE TRACKING VARIABLES # ============================================================================ # Associative arrays for tracking move statistics declare -A MOVED_FROM_TO_GB # Tracks GB moved between disk pairs (e.g., "disk1->disk2" = 5.2) declare -A DISK_SIMULATED_FREE # Tracks simulated free space after moves (for dry run accuracy) declare -A SKIPPED_PATHS # Tracks paths that were skipped due to errors # Counters for statistics TOTAL_MOVED_GB=0 # Total GB moved in this session MOVE_COUNT=0 # Number of items moved SIMULATED_PROCESSED_PATHS="" # Pipe-delimited list of paths already processed (prevents loops) # ============================================================================ # HELPER FUNCTIONS # ============================================================================ # Function: log_msg # Description: Outputs a timestamped message to the log # Parameters: $1 = message to log log_msg() { echo "$(date +'%Y-%m-%d %H:%M:%S') - $1"; } # Function: get_free_gb # Description: Gets the current free space of a disk in GB (base-10 units) # Parameters: $1 = disk path (e.g., /mnt/disk1) # Returns: Free space in GB with 4 decimal precision get_free_gb() { df --output=avail -B1 "$1" | tail -n 1 | awk '{printf "%.4f", $1 / 1000000000}'; } # Function: get_folder_size_gb # Description: Calculates folder size in GB, with fallback for different du versions # Parameters: $1 = folder path # Returns: Folder size in GB with 4 decimal precision, or empty if unable to determine # Note: Returns empty for empty folders or folders with permission issues get_folder_size_gb() { local raw_size # Try du -sb first (most accurate, shows apparent size) raw_size=$(du -sb "$1" 2>/dev/null | awk '{print $1}') if [ -z "$raw_size" ]; then # Fallback to du -sB1 if -sb fails (some systems) raw_size=$(du -sB1 "$1" 2>/dev/null | awk '{print $1}') fi if [ -n "$raw_size" ]; then echo "$raw_size" | awk '{printf "%.4f", $1 / 1000000000}' fi } # Function: check_path_safety # Description: Prevents dangerous operations on /mnt/user paths # Parameters: $1 = source path, $2 = destination path # Exit: Exits script with error if unsafe path detected # Rationale: Moving from /mnt/user can cause data loss due to FUSE caching issues check_path_safety() { if [[ "$1" == *"/mnt/user/"* ]] || [[ "$2" == *"/mnt/user/"* ]]; then log_msg "CRITICAL SAFETY ERROR: /mnt/user detected. Aborting." exit 1 fi } # Function: refresh_user_share # Description: Triggers a FUSE cache refresh for the user share # Parameters: $1 = destination path on disk # Note: This works around a Unraid FUSE caching issue where moved files # may not immediately appear in the user share view refresh_user_share() { local user_parent=$(dirname "$1" | sed 's|^/mnt/disk[0-9]*/|/mnt/user/|') [ -d "$user_parent" ] && touch "${user_parent}/.dsm_update" && rm "${user_parent}/.dsm_update" } # Function: find_target_disk # Description: Finds the best destination disk for a move operation # Parameters: $1 = source disk path, $2 = per-path exclusions (comma-separated) # Returns: Path to the disk with most free space, or empty if none available # Logic: Excludes source disk, globally excluded disks, and per-path excluded disks find_target_disk() { local src="$1" local best="" local max=0 local exclusions="$2" for disk in /mnt/disk[0-9]*; do # Skip if: same as source, globally excluded, or per-path excluded [[ "$disk" == "$src" ]] || [[ ",$EXCLUDED_DISKS," == *",$disk,"* ]] || [[ ",$exclusions," == *",$disk,"* ]] && continue local free=${DISK_SIMULATED_FREE[$(basename "$disk")]} local is_greater=$(awk -v f1="$free" -v f2="$max" 'BEGIN { print (f1 > f2) }') if [ "$is_greater" -eq 1 ]; then max=$free; best=$disk; fi done echo "$best" } # ============================================================================ # INITIALIZATION # ============================================================================ # Initialize simulated free space tracking for all disks # This array stores the current free space (real or simulated after moves) for disk in /mnt/disk[0-9]*; do DISK_SIMULATED_FREE[$(basename "$disk")]=$(get_free_gb "$disk"); done # Parse managed paths from config (format: path:sort_type:excluded_disks) IFS='|' read -r -a PATH_ARRAY <<< "$MANAGED_PATHS" # Track if any disk was below threshold (for logging) ANY_DISK_BELOW_THRESHOLD=false log_msg "--- Disk Space Management: Execution Started ---" log_msg "Threshold set to: ${THRESHOLD_GB}GB" [ "$DRY_RUN" == "true" ] && log_msg "*** DRY RUN MODE ENABLED ***" # ============================================================================ # MAIN DISK PROCESSING LOOP # ============================================================================ # Iterate through all disks and check if they need space management for disk in /mnt/disk[0-9]*; do D_NAME=$(basename "$disk") [[ ",$EXCLUDED_DISKS," == *",$disk,"* ]] && continue initial_free=$(get_free_gb "$disk") is_below=$(awk -v cur="$initial_free" -v thold="$THRESHOLD_GB" 'BEGIN { print (cur < thold) }') if [ "$is_below" -eq 1 ]; then ANY_DISK_BELOW_THRESHOLD=true log_msg "Disk $D_NAME is below threshold. Free: ${initial_free}GB." while true; do if [ -f "$STOP_FILE" ]; then log_msg "STOP SIGNAL DETECTED. Stopping gracefully."; break 2; fi curr_sim_free=${DISK_SIMULATED_FREE[$D_NAME]} is_now_above=$(awk -v cur="$curr_sim_free" -v thold="$THRESHOLD_GB" 'BEGIN { print (cur >= thold) }') if [ "$is_now_above" -eq 1 ]; then log_msg "Disk $D_NAME is now above threshold (${curr_sim_free}GB). Stopping moves." break fi found_move=false # ======================================================================== # MANAGED PATH ITERATION # ======================================================================== # Try each managed path in priority order until a valid move is found # Format: path:sort_type:excluded_disks for entry in "${PATH_ARRAY[@]}"; do # Parse the entry into its components rel_path="${entry%%:*}" # The relative path (e.g., "media/movies") rest="${entry#*:}" sort_type="${rest%%:*}" # Sorting method (smallest, largest, alphabetical, etc.) folder_ex="${rest#*:}" # Per-path disk exclusions # Skip this path if the current disk is in its exclusion list [[ ",$folder_ex," == *",$disk,"* ]] && continue # Find the best destination disk for this move target=$(find_target_disk "$disk" "$folder_ex") [ -z "$target" ] && continue # Build the full source directory path source_dir="$disk/$rel_path" [ ! -d "$source_dir" ] && continue # ==================================================================== # FOLDER SELECTION LOGIC # ==================================================================== # This section selects the next folder to move based on the sort type. # # IMPORTANT BUG FIX: Previously, when a folder was skipped (size 0), # the script would continue to the next managed path instead of trying # the next folder in the same path. This caused disks to remain under # the threshold even when valid folders existed. # # The fix: We filter out already-processed paths during selection, # so the next iteration picks the next valid folder from the same path. # ==================================================================== # Build a list of candidate folders with their sort keys # Filter out: already processed paths, paths with size 0 (handled later) item=$(while IFS= read -r f; do # Skip if already processed in this session [[ "$SIMULATED_PROCESSED_PATHS" == *"$f|"* ]] && continue # Generate sort key based on sort type case "$sort_type" in "smallest"|"largest") # Size-based: get folder size in bytes s=$(du -sb "$f" 2>/dev/null | awk '{print $1}') [ -n "$s" ] && echo "$s|$f" ;; "oldest"|"newest") # Time-based: get modification timestamp t=$(stat -c %Y "$f" 2>/dev/null) [ -n "$t" ] && echo "$t|$f" ;; *) # Alphabetical: use folder name echo "$(basename "$f")|$f" ;; esac done < <(find "$source_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) | case "$sort_type" in # Sort based on sort type "smallest"|"oldest") sort -n 2>/dev/null ;; # Numeric ascending "largest"|"newest") sort -rn 2>/dev/null ;; # Numeric descending "alphabetical_reversed") sort -rf 2>/dev/null ;; # Reverse alphabetical *) sort -f 2>/dev/null ;; # Case-insensitive alphabetical esac | head -n 1 | cut -d'|' -f2) # ==================================================================== # MOVE EXECUTION # ==================================================================== if [ -n "$item" ]; then size=$(get_folder_size_gb "$item") # ---------------------------------------------------------------- # SAFETY CHECK: Skip folders with invalid/zero size # ---------------------------------------------------------------- # This prevents infinite loops when: # - Folder is empty (leftover from previous moves) # - Folder is locked/in use by another process # - Permission issues prevent size calculation # # BUG FIX: We do NOT use 'continue' here. Instead, we add the item # to SIMULATED_PROCESSED_PATHS and let the while loop iterate again. # This ensures the next valid folder is selected from the same path. # ---------------------------------------------------------------- if [ -z "$size" ] || [ "$size" == "0.0000" ]; then log_msg "WARNING: Unable to determine size of '$item'. File might be in use/locked or empty. Skipping to avoid loop." SIMULATED_PROCESSED_PATHS+="$item|" SKIPPED_PATHS["$item"]=1 # Don't continue - let the while loop iterate again to try the next item # This fixes the bug where skipped items prevented further moves on the same disk else # ---------------------------------------------------------------- # VALID FOLDER FOUND - Proceed with move # ---------------------------------------------------------------- target_free=${DISK_SIMULATED_FREE[$(basename "$target")]} has_space=$(awk -v free="$target_free" -v sz="$size" 'BEGIN { print (free >= sz) }') if [ "$has_space" -eq 0 ]; then log_msg "WARNING: Folder '$item' ($size GB) won't fit on target disk $(basename "$target") (${target_free} GB free). Please change your moving sorting order or add more drives to get more space. Skipping to next configured path." continue fi dst="$target/$rel_path/$(basename "$item")" check_path_safety "$item" "$dst" # ---------------------------------------------------------------- # DRY RUN MODE: Simulate the move without actually moving files # ---------------------------------------------------------------- if [ "$DRY_RUN" == "true" ]; then log_msg "[DRY RUN] Would move: '$item' ($size GB) to '$dst'" SIMULATED_PROCESSED_PATHS+="$item|" # Update simulated free space counters TOTAL_MOVED_GB=$(awk -v cur="$TOTAL_MOVED_GB" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') ((MOVE_COUNT++)) DISK_SIMULATED_FREE[$D_NAME]=$(awk -v cur="${DISK_SIMULATED_FREE[$D_NAME]}" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') DISK_SIMULATED_FREE[$(basename "$target")]=$(awk -v cur="${DISK_SIMULATED_FREE[$(basename "$target")]}" -v sz="$size" 'BEGIN { printf "%.4f", cur - sz }') key="${D_NAME}->$(basename "$target")"; MOVED_FROM_TO_GB[$key]=$(awk -v cur="${MOVED_FROM_TO_GB[$key]:-0}" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') found_move=true; break # ---------------------------------------------------------------- # LIVE MODE: Actually move the files # ---------------------------------------------------------------- else log_msg "Moving: '$item' ($size GB) to '$dst'" mkdir -p "$(dirname "$dst")" # Use rsync with --remove-source-files for atomic moves # This preserves attributes and handles partial transfers if rsync -aH --remove-source-files "$item/" "$dst/"; then # Clean up empty subdirectories left by rsync # rsync --remove-source-files only removes files, not directories find "$item" -mindepth 1 -type d -empty -delete 2>/dev/null # Remove the now-empty source folder if [ -z "$(ls -A "$item")" ]; then if rm -rf "$item"; then refresh_user_share "$dst" else log_msg "ERROR: Failed to remove empty source folder '$item'." fi else log_msg "WARNING: Source folder '$item' not removed. It contains leftover files." fi # Update statistics TOTAL_MOVED_GB=$(awk -v cur="$TOTAL_MOVED_GB" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') ((MOVE_COUNT++)) DISK_SIMULATED_FREE[$D_NAME]=$(awk -v cur="${DISK_SIMULATED_FREE[$D_NAME]}" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') DISK_SIMULATED_FREE[$(basename "$target")]=$(awk -v cur="${DISK_SIMULATED_FREE[$(basename "$target")]}" -v sz="$size" 'BEGIN { printf "%.4f", cur - sz }') key="${D_NAME}->$(basename "$target")"; MOVED_FROM_TO_GB[$key]=$(awk -v cur="${MOVED_FROM_TO_GB[$key]:-0}" -v sz="$size" 'BEGIN { printf "%.4f", cur + sz }') found_move=true; break else # rsync failed - likely file in use or permission issue log_msg "ERROR: Failed to move '$item'. File might be in use/locked. Skipping to avoid loop." SIMULATED_PROCESSED_PATHS+="$item|" SKIPPED_PATHS["$item"]=1 fi fi fi fi done # If no move was found across all managed paths, exit the while loop [ "$found_move" = false ] && break done fi done # ============================================================================ # COMPLETION LOGGING # ============================================================================ # Log appropriate message based on whether any work was needed if [ "$ANY_DISK_BELOW_THRESHOLD" = false ]; then log_msg "All disks are above the threshold. No files need to be moved." fi log_msg "--- Disk Space Management: Execution Finished ---" # ============================================================================ # NOTIFICATION GENERATION # ============================================================================ # Send a summary notification to the Unraid WebUI and notification agents SCRIPT_END_TIME=$(date +"%Y-%m-%d %H:%M:%S") if [ "$NOTIFY" = "true" ]; then # Build the notification body with HTML for UI and plain text for agents ui_body="Run finished. Total moved: $(printf "%.2f" "$TOTAL_MOVED_GB")GB in $MOVE_COUNT items.

"; [ "$DRY_RUN" == "true" ] && ui_body+="DRY RUN: No files moved.
"; ui_body+="Start: $SCRIPT_START_TIME
End: $SCRIPT_END_TIME

Disk Summary:
" agent_msg="DSM Summary\nTotal: $(printf "%.2f" "$TOTAL_MOVED_GB")GB\n" # Add per-disk-pair move statistics for k in "${!MOVED_FROM_TO_GB[@]}"; do src=${k%->*} total=${MOVED_FROM_TO_GB[$k]} final=${DISK_SIMULATED_FREE[$src]} ui_body+=" - $k: $(printf "%.2f" "$total")GB. New free: $(printf "%.2f" "$final")GB.
" agent_msg+=" - $k: $(printf "%.2f" "$total")GB. New free: $(printf "%.2f" "$final")GB\n" done # Add skipped paths section if any paths were skipped if [ "${#SKIPPED_PATHS[@]}" -gt 0 ]; then ui_body+="
Skipped paths (Size 0/Error):
" agent_msg+="\nSkipped paths (Size 0/Error):\n" for p in "${!SKIPPED_PATHS[@]}"; do ui_body+="$p
" agent_msg+="$p\n" done fi # Append first 200 lines of log for agent notifications (email, etc.) agent_msg+="\n--- Log (First 200 lines) ---\n$(head -n 200 "$TEMP_LOG_FILE")" # Send notification via Unraid's notify system # -e = event type, -s = subject, -d = display message (HTML), -m = full message (plain text) /usr/local/emhttp/plugins/dynamix/scripts/notify -e "Disk Space Management" -s "Run Summary" -d "$ui_body" -m "$agent_msg" fi ]]>
"$CFG" fi ]]>