]> # 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 ]]> $_POST['THRESHOLD_GB'] ?? '50', 'DRY_RUN' => $_POST['DRY_RUN'] ?? 'true', '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"; } file_put_contents(CONFIG_FILE, $content); $cron_file = "/boot/config/plugins/dynamix/" . PLUGIN_NAME . ".cron"; if (strtolower($schedule) !== 'disabled') { $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"); echo "success"; 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'); ?>

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. Enter "disabled" to turn off. 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 CONFIG_FILE="/boot/config/plugins/DiskSpaceManagement/settings.cfg" get_cfg() { grep "^$1=" "$CONFIG_FILE" | cut -d'"' -f2; } 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") THRESHOLD_GB=${THRESHOLD_GB:-50} DRY_RUN=${DRY_RUN:-true} LOG_FILE=${LOG_FILE:-/var/log/diskspacemanagement.log} NOTIFY=${NOTIFY:-true} 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 declare -A MOVED_FROM_TO_GB declare -A DISK_SIMULATED_FREE declare -A SKIPPED_PATHS TOTAL_MOVED_GB=0 MOVE_COUNT=0 SIMULATED_PROCESSED_PATHS="" log_msg() { echo "$(date +'%Y-%m-%d %H:%M:%S') - $1"; } get_free_gb() { df --output=avail -B1 "$1" | tail -n 1 | awk '{printf "%.4f", $1 / 1000000000}'; } # Fallback size calculation: tries 'du -sb' first, then 'du -sB1' if that fails get_folder_size_gb() { local raw_size raw_size=$(du -sb "$1" 2>/dev/null | awk '{print $1}') if [ -z "$raw_size" ]; then 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 } check_path_safety() { if [[ "$1" == *"/mnt/user/"* ]] || [[ "$2" == *"/mnt/user/"* ]]; then log_msg "CRITICAL SAFETY ERROR: /mnt/user detected. Aborting." exit 1 fi } 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" } find_target_disk() { local src="$1" local best="" local max=0 local exclusions="$2" for disk in /mnt/disk[0-9]*; do [[ "$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" } for disk in /mnt/disk[0-9]*; do DISK_SIMULATED_FREE[$(basename "$disk")]=$(get_free_gb "$disk"); done IFS='|' read -r -a PATH_ARRAY <<< "$MANAGED_PATHS" log_msg "--- Disk Space Management: Execution Started ---" log_msg "Threshold set to: ${THRESHOLD_GB}GB" [ "$DRY_RUN" == "true" ] && log_msg "*** DRY RUN MODE ENABLED ***" 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 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 for entry in "${PATH_ARRAY[@]}"; do rel_path="${entry%%:*}"; rest="${entry#*:}"; sort_type="${rest%%:*}"; folder_ex="${rest#*:}" [[ ",$folder_ex," == *",$disk,"* ]] && continue target=$(find_target_disk "$disk" "$folder_ex") [ -z "$target" ] && continue source_dir="$disk/$rel_path" [ ! -d "$source_dir" ] && continue # Selection Logic: Validates sorting for all 5 types item=$(find "$source_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while read f; do [[ "$SIMULATED_PROCESSED_PATHS" == *"$f|"* ]] && continue case "$sort_type" in "smallest") s=$(du -sb "$f" | awk '{print $1}'); echo "$s|$f" ;; "oldest"|"newest") t=$(stat -c %Y "$f"); echo "$t|$f" ;; *) echo "$(basename "$f")|$f" ;; esac done | case "$sort_type" in "smallest"|"oldest") sort -n 2>/dev/null ;; "newest") sort -rn 2>/dev/null ;; "alphabetical_reversed") sort -rf 2>/dev/null ;; *) sort -f 2>/dev/null ;; esac | head -n 1 | cut -d'|' -f2) if [ -n "$item" ]; then size=$(get_folder_size_gb "$item") # SAFETY CHECK: If size is missing or 0, skip to prevent infinite loop if [ -z "$size" ] || [ "$size" == "0.0000" ]; then log_msg "WARNING: Unable to determine size of '$item'. File might be in use/locked. Skipping to avoid loop." SIMULATED_PROCESSED_PATHS+="$item|" SKIPPED_PATHS["$item"]=1 continue fi dst="$target/$rel_path/$(basename "$item")" check_path_safety "$item" "$dst" if [ "$DRY_RUN" == "true" ]; then log_msg "[DRY RUN] Would move: '$item' ($size GB) to '$dst'" SIMULATED_PROCESSED_PATHS+="$item|" 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 log_msg "Moving: '$item' ($size GB) to '$dst'" mkdir -p "$(dirname "$dst")" if rsync -aH --remove-source-files "$item/" "$dst/"; then # Cleanup: Remove empty subdirectories left by rsync --remove-source-files find "$item" -mindepth 1 -type d -empty -delete 2>/dev/null 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 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 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 done [ "$found_move" = false ] && break done fi done log_msg "--- Disk Space Management: Execution Finished ---" SCRIPT_END_TIME=$(date +"%Y-%m-%d %H:%M:%S") if [ "$NOTIFY" = "true" ]; then 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" 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 Failed/Skipped items to notification 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 agent_msg+="\n--- Log (First 200 lines) ---\n$(head -n 200 "$TEMP_LOG_FILE")" /usr/local/emhttp/plugins/dynamix/scripts/notify -e "Disk Space Management" -s "Run Summary" -d "$ui_body" -m "$agent_msg" fi ]]>
"$CFG" fi ]]>