Skip to content
View in the app

A better way to browse. Learn more.

Unraid

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

[User script - Disk space management] Are you using split level and are tired of moving things around manually to make more space on your drives? This is the script for you.

Featured Replies

Edit: This script is now added as a plugin. You can find it in CA. Users are encouraged to install the plugin instead as I will no longer update the script.

I'm using split level to satisfy my OCD :) And with that comes weekly maintenance of moving stuff around on drives that are almost full to free up space for new/upgraded files. Up until now, I've been doing this manually every weekend. But now, with the new Huntarr app, which constantly hunts for new files or upgrades files, moving files around suddenly became a much bigger task and has to be done daily. So I had to do something, either stop using split level and split everything as needed, or write a script that moves stuff around to drives with more space available. So I did the latter, or AI did most of the work. So now I can continue to use split level for my OCD, and never run out of space :) And I don't need to lift a finger anymore. So naturally, I'll share it with anyone who needs it.

This Bash script is designed to automate the management of disk space on an Unraid server by monitoring available space on disks, moving media files from full disks to those with the most available space, and creating necessary directories as needed. This ensures that your split-level will continue to organize the files where you want them. Without running out of space. It incorporates robust logging to track actions taken and any issues encountered throughout the execution. It also has a prioritizing logic that prioritizes moving movies first (since a movie often takes less space than a full show). And if it moves shows, it will always move the show with the fewest seasons first to maximize efficiency.

It also moves directly from disk to disk and thus bypasses the cache. It only focuses on the drives in the main array and does not include pools. And it assumes that each movie has its own folder, and each show has a main show folder with season subfolders.

The majority of my server is dedicated to media, and thus it makes the most sense to me to move media around, as those are the files that take up the most space on my server. The script and variable definition reflect that, and note that the paths are relative, not absolute.

Run it preferably after the mover has finished or whatever schedule you like.

Here is the script:

unraid_disk_space_management script

#!/bin/bash

# ==============================================================================
# Unraid Disk Space Management Script
#
# Version: 2.16
#
# Description:
# This script automates disk space management on an Unraid server. It monitors
# disks, moves media files from full disks to those with more space, and
# ensures media is organized correctly.
#
# Features:
# - Monitors disks based on a defined free space threshold.
# - Moves movies and TV shows to maintain free space.
# - Prioritizes moving movies and smaller TV shows first to be more efficient.
# - Supports multiple media directory locations.
# - Allows for specific disks to be excluded from operations.
# - Uses rsync to preserve permissions, attributes, and hard links.
# - Includes a smart DRY RUN mode for safe, accurate testing.
# - Robust logging for all actions.
# ==============================================================================

# --- Configuration ---

# Threshold for disk space in GB. If a disk has less than this, it's considered full.
THRESHOLD_GB=50

# Set to "true" to see what the script would do without actually moving files.
# Set to "false" for normal operation.
DRY_RUN=true

# Log file for recording actions.
LOG_FILE="/mnt/user/share/script logs/unraid_disk_space_management.log"

# --- Directory and Disk Definitions ---

# Define one or more relative paths for your Movie libraries.
# Example: MOVIE_DIRS=("share/Movies" "share/Movies4K")
MOVIE_DIRS=("share/Movies")

# Define one or more relative paths for your TV Show libraries.
# Example: TV_SHOW_DIRS=("share/Tv Shows" "share/Kid Shows")
TV_SHOW_DIRS=("share/Tv Shows")

# List of disks to exclude from all operations (source and destination).
# Paths can be with or without the leading slash (e.g., "/mnt/disk10" or "mnt/disk10").
# Example: EXCLUDED_DISKS=("/mnt/disk10" "mnt/disk11")
EXCLUDED_DISKS=()

# --- Script Logic ---

# Global variable to hold the result from move_folder_rsync to avoid output capture issues.
LAST_MOVE_SIZE_GB=0

# Ensure log directory exists
mkdir -p "$(dirname "$LOG_FILE")"

# Function to log messages with a timestamp to console and log file
log_message() {
    local message
    message="$(date +'%Y-%m-%d %H:%M:%S') - $1"
    # Write sequentially to console and log file to prevent race conditions.
    echo "$message"
    echo "$message" >> "$LOG_FILE"
}

# Function to send an Unraid notification
send_notification() {
    local message="$1"
    /usr/local/emhttp/plugins/dynamix.plugin.manager/scripts/notify -s "Disk Space Management" -d "$message"
}

# Function to check if a disk is in the excluded list
is_disk_excluded() {
    local disk_to_check="$1"
    local normalized_disk_to_check="${disk_to_check#/}"

    for excluded in "${EXCLUDED_DISKS[@]}"; do
        local normalized_excluded="${excluded#/}"
        if [[ "$normalized_disk_to_check" == "$normalized_excluded" ]]; then
            return 0 # 0 means true (is excluded)
        fi
    done
    return 1 # 1 means false (is not excluded)
}

# Function to get the size of a folder in GB
get_folder_size_gb() {
    local folder_path="$1"
    local size_gb
    size_gb=$(du -sBG "$folder_path" 2>/dev/null | awk '{print $1}' | tr -d 'G')
    if ! [[ "$size_gb" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
        echo "0"
    else
        echo "$size_gb"
    fi
}

# Function to check if a disk is below the free space threshold
is_disk_almost_full() {
    local disk="$1"
    local simulated_freed_space=${2:-0}
    local current_free_space
    current_free_space=$(df -BG "$disk" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')
    
    # Sanitize inputs for awk
    if ! [[ "$current_free_space" =~ ^[0-9]+([.][0-9]+)?$ ]]; then current_free_space=0; fi
    if ! [[ "$simulated_freed_space" =~ ^[0-9]+([.][0-9]+)?$ ]]; then simulated_freed_space=0; fi

    local comparison
    comparison=$(awk -v cur="$current_free_space" -v sim="$simulated_freed_space" -v thold="$THRESHOLD_GB" 'BEGIN { print (cur + sim < thold) }')

    if [[ "$comparison" -eq 1 ]]; then
        return 0 # Is almost full
    else
        return 1 # Is not almost full
    fi
}

# Function to find the best target disk (most space, not excluded, not the source)
find_target_disk() {
    local source_disk="$1"
    local best_disk=""
    local max_free_space=0

    while IFS= read -r line; do
        local disk_path
        local free_space_gb
        disk_path=$(echo "$line" | awk '{print $NF}')
        free_space_gb=$(echo "$line" | awk '{print $4}' | tr -d 'G')
        if ! [[ "$free_space_gb" =~ ^[0-9]+([.][0-9]+)?$ ]]; then continue; fi

        if [[ "$disk_path" == "$source_disk" ]]; then
            continue
        fi

        if is_disk_excluded "$disk_path"; then
            continue
        fi
        
        local is_greater
        is_greater=$(awk -v f1="$free_space_gb" -v f2="$max_free_space" 'BEGIN { print (f1 > f2) }')
        if [ "$is_greater" -eq 1 ]; then
            max_free_space=$free_space_gb
            best_disk=$disk_path
        fi
    done < <(df -BG | grep '/mnt/disk[0-9]\+')

    echo "$best_disk"
}

# Function to move a folder using rsync.
move_folder_rsync() {
    local source_path="$1"
    local target_dir="$2"
    local source_folder_name
    source_folder_name=$(basename "$source_path")
    local full_target_path="$target_dir/$source_folder_name"
    
    # Reset the global variable
    LAST_MOVE_SIZE_GB=0

    if [[ ! -d "$target_dir" ]]; then
        log_message "Creating target directory: $target_dir"
        if [ "$DRY_RUN" = "false" ]; then
            mkdir -p "$target_dir"
            chown nobody:users "$target_dir"
        fi
    fi
    
    local rsync_cmd="rsync -aH --info=progress2 --remove-source-files \"$source_path/\" \"$full_target_path/\""

    if [ "$DRY_RUN" = "true" ]; then
        local folder_size_gb
        folder_size_gb=$(get_folder_size_gb "$source_path")
        log_message "[DRY RUN] Would move: '$source_path' ($folder_size_gb GB) to '$full_target_path'"
        log_message "[DRY RUN] Would execute: $rsync_cmd"
        # Set global variable instead of echoing to avoid capturing log output.
        LAST_MOVE_SIZE_GB=$folder_size_gb
        return 0
    fi

    log_message "Preparing to move: '$source_path' to '$full_target_path'"
    log_message "Executing: $rsync_cmd"
    if eval "$rsync_cmd"; then
        log_message "Successfully moved '$source_path' to '$full_target_path'"
        # BUG FIX: Use rm -rf instead of rmdir to remove the source directory
        # and any empty subdirectories left behind by rsync.
        rm -rf "$source_path"
        return 0
    else
        log_message "ERROR: rsync failed to move '$source_path'. See rsync output above."
        send_notification "ERROR: rsync failed to move '$source_path'."
        return 1
    fi
}

# --- Main Execution ---
log_message "--- Script starting ---"
if [ "$DRY_RUN" = "true" ]; then
    log_message "*** DRY RUN MODE ENABLED *** No files will be moved."
fi

mounted_disks=$(df | grep '/mnt/disk[0-9]\+' | awk '{print $NF}' | sort)

for disk in $mounted_disks; do
    if is_disk_excluded "$disk"; then
        log_message "Skipping excluded disk: $disk"
        continue
    fi

    # Only process if the disk is initially full.
    if ! is_disk_almost_full "$disk"; then
        continue
    fi
    
    initial_free_space=$(df -BG "$disk" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')
     if ! [[ "$initial_free_space" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
        initial_free_space="N/A"
    fi
    log_message "Disk $disk is below the ${THRESHOLD_GB}GB threshold (initial space: ${initial_free_space}GB). Planning moves..."

    item_list=$(
        # Priority 1: Movies (sort key 0)
        for dir in "${MOVIE_DIRS[@]}"; do
             find "$disk/$dir" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | while IFS= read -r -d '' folder; do
                echo "1|0|$folder|$dir"
            done
        done
        # Priority 2: TV Shows (sort key is season count)
        for dir in "${TV_SHOW_DIRS[@]}"; do
            find "$disk/$dir" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null | while IFS= read -r -d '' folder; do
                seasons_count=$(find "$folder" -mindepth 1 -maxdepth 1 -type d | wc -l)
                echo "2|$seasons_count|$folder|$dir"
            done
        done
    )

    sorted_item_list=$(echo "$item_list" | sort -t'|' -k1,1n -k2,2n)

    if [[ -z "$sorted_item_list" ]]; then
        log_message "No movable files found on $disk."
        continue
    fi
    
    simulated_freed_space_gb=0
    target_disk=$(find_target_disk "$disk")
    if [[ -z "$target_disk" ]]; then
        log_message "No suitable target disk found for source $disk."
        continue
    fi
    log_message "Best target disk found: $target_disk"

    while IFS='|' read -r priority sort_key folder_path target_base_dir; do
        # Logic is checked BEFORE each move to prevent overshoot.
        if ! is_disk_almost_full "$disk" "$simulated_freed_space_gb"; then
            current_free_space=$(df -BG "$disk" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')
            if ! [[ "$current_free_space" =~ ^[0-9]+([.][0-9]+)?$ ]]; then current_free_space=0; fi
            effective_space=$(awk -v cur="$current_free_space" -v sim="$simulated_freed_space_gb" 'BEGIN { print cur + sim }')
            log_message "Disk $disk is now above the threshold (effective space: ${effective_space}GB). Halting moves for this disk."
            break 
        fi
        
        if [[ -z "$folder_path" ]]; then continue; fi
        
        if [[ "$priority" -eq 2 ]]; then
             log_message "Found TV show: '$(basename "$folder_path")' with $sort_key seasons."
        fi

        if [ "$DRY_RUN" = "true" ]; then
            # Call the function directly, then read the global variable.
            # This prevents capturing log output and corrupting the calculation.
            move_folder_rsync "$folder_path" "$target_disk/$target_base_dir"
            size_moved=$LAST_MOVE_SIZE_GB
            simulated_freed_space_gb=$(awk -v cur="${simulated_freed_space_gb:-0}" -v moved="${size_moved:-0}" 'BEGIN { print cur + moved }')
        else
            if ! move_folder_rsync "$folder_path" "$target_disk/$target_base_dir"; then
                log_message "A real move failed. Stopping further moves from $disk."
                break
            fi
        fi
    done <<< "$sorted_item_list"
done

log_message "--- Script finished ---"

And here is how it works in detail for those interested:

How It works

How the Unraid Disk Management Script (v2.16) Works

This document explains the step-by-step logic of the script, highlighting the key features that make it robust and reliable.

1. Configuration Section

This is the control panel for the script, where you define its core behavior:

  • THRESHOLD_GB: The free space limit, in gigabytes. The script will only take action on a disk if its free space drops below this value.

  • DRY_RUN: The critical safety switch. When true, the script logs all its intended actions without making any actual changes. When false, it performs the real moves.

  • MOVIE_DIRS & TV_SHOW_DIRS: These are arrays where you list all the relative paths to your different media libraries.

  • EXCLUDED_DISKS: A list of disks (like /mnt/disk10) that the script will completely ignore for both moving files from and to.

2. The Main Loop & Initial Check

The script starts by getting a list of all your mounted array disks. It then loops through each one and performs two initial checks:

  1. Is the disk on the EXCLUDED_DISKS list? If so, it's skipped.

  2. Is the disk's free space below the THRESHOLD_GB? If not, it's skipped.

If a disk passes these checks (it's not excluded and it's almost full), the script proceeds to the planning phase.

3. The Planning Phase: "Plan Once, Execute Sequentially"

This is the most critical part of the script's logic. Instead of making decisions on the fly, it first creates a complete, intelligent "to-do list" for the entire disk.

  1. Build a Master List: The script scans all directories defined in MOVIE_DIRS and TV_SHOW_DIRS on the specific disk that is almost full. It builds a single list of every potential movie or TV show folder it could move.

  2. Assign Priorities: As it builds this list, it tags each item with crucial sorting information:

    • Movies are given Priority 1.

    • TV Shows are given Priority 2.

    • For TV shows, it also records the season count as a secondary sorting key.

  3. Sort the Master List: It then performs a multi-level sort on this master list. The sort command is configured to order items first by priority (so all Priority 1 movies come before any Priority 2 shows), and then by the secondary key (so TV shows are sub-sorted by their season count, lowest first).

The outcome is a perfectly ordered plan, ensuring the most efficient move is always at the top of the list. This "plan once" approach prevents the script from getting stuck in loops re-evaluating the same files.

4. The Execution Loop: One Item at a Time

With the sorted plan in hand, the script enters its main processing loop. This loop is designed to be very deliberate and safe:

  1. Check First, Act Second: Before it even looks at the next item in the plan, it checks the disk's free space again. If the previous move was enough to push the disk's free space (real or simulated) above the THRESHOLD_GB, it immediately stops processing for that disk and breaks out of the loop. This is the crucial fix that prevents the "overshoot" problem.

  2. Process One Item: It reads the top item from the sorted list and calls the move_folder_rsync function to handle it.

  3. Update Space & Repeat: For a DRY_RUN, it updates its simulated_freed_space_gb variable. For a real run, the disk space is updated naturally. The loop then repeats, starting again with the "Check First" step.

Edited by strike

I know lots of people have always wanted something like this. Have you considered making this a Plugin?

Also is there a possibility of baking in a dry run feature so people can test without actually moving files?

  • Author
1 hour ago, kizer said:

I know lots of people have always wanted something like this. Have you considered making this a Plugin?

Also is there a possibility of baking in a dry run feature so people can test without actually moving files?

Yeah, I always wanted something like this myself. It just didn't register in my brain until a few days ago that this easily scripted and I could automate it.

Sure, a dry run is easy to implement. I might have to change it so it uses rsync instead of the mv command, but that is doable. I might change it to use rsync anyway as it will preserve hard links. I don't think the mv command does that when moving to other drives.

I will also look into making it a plugin. It could be a great learning experience as I've never wrote a plugin before. ☺️

Great idea,

For rsync, or mv, maybe you can give the choice, like mover tuning (rsync or mover).

May be option to exclude 1 or more drive. In my case, disk1 is reserved for specifics files, and is excluded for films or tv shows.

Would it be possible to have more than 1 folder for tv shows ?. I have 2 dedicated folders, 1 for tv shows multi language, and one for tv shows in VO.

  • Author

I think it would be best to only use rsync because as I mentioned it will preserve hard links. But I'll look into it. And when I said the the mv command it's not the same as its using mover.

Excluding drives and add multiple folders for tv shows should also be pretty easy to implement. The script is already excluding disks, which is unassigned drives.

Gave this a run on my Backup Server knowing that I could easily restore anything that messes up.

How often does it check to look at available space? I told it to move until I'm at 999GB when the drive was sitting at 994GB on a drive and it only had to move 5GB and now its 16 Movies in when I'm sure 1 or 2 would of been perfectly fine. I like how its keeping the time/date stamps on the files.

  • Author
53 minutes ago, kizer said:

Gave this a run on my Backup Server knowing that I could easily restore anything that messes up.

How often does it check to look at available space? I told it to move until I'm at 999GB when the drive was sitting at 994GB on a drive and it only had to move 5GB and now its 16 Movies in when I'm sure 1 or 2 would of been perfectly fine. I like how its keeping the time/date stamps on the files.

It checks available space after every successful move of a movie or tv show. But in its current form, it checks ALL drives. So, if you have more than one drive, it will continue to move until all drives are above the threshold. In your case 999GB. So, setting a lower number, what you want as free space on the drives, is what you should do. Or do you have a better suggestion on how I could handle it? So, am I assuming correctly that you have more than 1 drive in your backup server? Since it's continuing to move.. And for movies, it moves in alphabetical order.

  • Author
1 hour ago, kizer said:

Gave this a run on my Backup Server knowing that I could easily restore anything that messes up.

How often does it check to look at available space? I told it to move until I'm at 999GB when the drive was sitting at 994GB on a drive and it only had to move 5GB and now its 16 Movies in when I'm sure 1 or 2 would of been perfectly fine. I like how its keeping the time/date stamps on the files.

If you want to test it further (before I have the time to implement the suggestions you guys came up with), you should pick the drive with the lowest amount of space, then set the threshold a little above that. So it doesn't continue to move a whole lot unless you want it to. And for testing purposes, just make a dummy "Movies" and "TV show" folder somewhere on a share and put a couple of movies and shows there. Just update the path in the variable definition before you run it again.

  • Author

So I finally had some time to work on the script, and it's now completely rewritten. I've implemented the suggestions you guys came up with. So it now features "dry run mode" (enabled by default), support for multiple movies/tv shows directories, and you can exclude one or more drives. I've updated the first post with the script and also the how it works section.

I thought about making it even more efficient by checking the disk usage for each movie/show and moving the smallest first, but that would make it slower and more I/O Intensive, and I'm not sure it's worth it, especially for large libraries. Right now, the script itself is very fast (the moving, of course, takes a bit of time), and works well.

I will now see if I can focus on how to turn it into a plugin.

Edited by strike

  • 4 weeks later...
  • Author

The plugin is now available in CA. Anyone using the script is encouraged to install the plugin as the script will no longer get updated.

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.