#!/bin/bash
set -euo pipefail # Exit on error, undefined variables, and pipe failures
# =======================================
# Script: qBittorrent Cache Mover - Start
# Version: 1.2.1
# Updated: 20260129
# =======================================
# Script version and update check URLs
readonly SCRIPT_VERSION="1.2.1"
readonly SCRIPT_RAW_URL="https://raw.githubusercontent.com/TRaSH-Guides/Guides/refs/heads/master/includes/downloaders/mover-tuning-start.sh"
readonly CONFIG_RAW_URL="https://raw.githubusercontent.com/TRaSH-Guides/Guides/refs/heads/master/includes/downloaders/mover-tuning.cfg"
# Get the directory where the script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source the config from the same directory
source "$SCRIPT_DIR/mover-tuning.cfg"
readonly VENV_PATH="${QBIT_MOVER_PATH}.venv"
readonly MOVER_SCRIPT="${QBIT_MOVER_PATH}mover.py"
readonly MOVER_URL="https://raw.githubusercontent.com/StuffAnThings/qbit_manage/develop/scripts/mover.py"
# Notification delay in seconds (helps ensure all notifications appear in Unraid)
NOTIFICATION_DELAY=2
# ================================
# UTILITY FUNCTIONS
# ================================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
error() {
log "ERROR: $*" >&2
exit 1
}
notify() {
local subject="$1"
local description="$2"
local notify_cmd="/usr/local/emhttp/plugins/dynamix/scripts/notify"
if [[ -x "$notify_cmd" ]]; then
"$notify_cmd" -s "$subject" -d "$description"
# Add delay after each notification to prevent dropping
sleep "$NOTIFICATION_DELAY"
fi
}
check_command() {
command -v "$1" &> /dev/null
}
set_ownership() {
chown -R nobody:users "$1" 2>/dev/null || log "⚠ Warning: Could not set ownership for $1"
}
# ================================
# CONFIG FORMAT DETECTION
# ================================
detect_config_format() {
# Check if array-based config is used
if [[ -v HOSTS[@] ]] && [[ ${#HOSTS[@]} -gt 0 ]]; then
echo "array"
else
echo "legacy"
fi
}
get_instance_count() {
local format
format=$(detect_config_format)
if [[ "$format" == "array" ]]; then
echo "${#HOSTS[@]}"
else
# Legacy format: count based on ENABLE_QBIT_2
if [[ "${ENABLE_QBIT_2:-false}" == true ]]; then
echo "2"
else
echo "1"
fi
fi
}
get_instance_details() {
local index="$1"
local format
format=$(detect_config_format)
if [[ "$format" == "array" ]]; then
# Array format: set global variables
INSTANCE_NAME="${NAMES[$index]:-qBit-Instance-$((index + 1))}"
INSTANCE_HOST="${HOSTS[$index]}"
INSTANCE_USER="${USERS[$index]}"
INSTANCE_PASSWORD="${PASSWORDS[$index]}"
else
# Legacy format: map index to old variables
if [[ $index -eq 0 ]]; then
INSTANCE_NAME="${QBIT_NAME_1}"
INSTANCE_HOST="${QBIT_HOST_1}"
INSTANCE_USER="${QBIT_USER_1}"
INSTANCE_PASSWORD="${QBIT_PASS_1}"
elif [[ $index -eq 1 ]]; then
INSTANCE_NAME="${QBIT_NAME_2}"
INSTANCE_HOST="${QBIT_HOST_2}"
INSTANCE_USER="${QBIT_USER_2}"
INSTANCE_PASSWORD="${QBIT_PASS_2}"
else
error "Invalid instance index: $index"
fi
fi
}
# ================================
# VERSION CHECK FUNCTION
# ================================
check_script_version() {
log "Checking for script updates..."
# Check if version check is enabled
if [[ "${ENABLE_VERSION_CHECK:-true}" != "true" ]]; then
log "Version check disabled"
return 0
fi
# Check for curl or wget
local fetch_cmd
if command -v curl &> /dev/null; then
fetch_cmd="curl -s"
elif command -v wget &> /dev/null; then
fetch_cmd="wget -qO-"
else
log "⚠ Cannot check version: curl or wget not found (continuing anyway)"
return 0
fi
# Fetch the latest version from the raw script URL
local remote_content
remote_content=$($fetch_cmd "$SCRIPT_RAW_URL" 2>/dev/null) || true
if [[ -z "$remote_content" ]]; then
log "⚠ Could not fetch latest version from GitHub (continuing anyway)"
return 0
fi
# Extract version from the remote script
local latest_version
latest_version=$(echo "$remote_content" | grep -m1 "^readonly SCRIPT_VERSION=" | sed 's/readonly SCRIPT_VERSION="\(.*\)"/\1/' 2>/dev/null) || true
if [[ -z "$latest_version" ]]; then
log "⚠ Could not parse version from remote script (continuing anyway)"
return 0
fi
log "Current version: $SCRIPT_VERSION"
log "Latest version: $latest_version"
# Compare versions
if [[ "$SCRIPT_VERSION" != "$latest_version" ]]; then
# Simple version comparison (works for semantic versioning)
# Sort both versions and check if SCRIPT_VERSION comes first (is older)
local oldest_version
oldest_version=$(printf '%s\n' "$latest_version" "$SCRIPT_VERSION" | sort -V | head -n1)
if [[ "$oldest_version" == "$SCRIPT_VERSION" ]]; then
# SCRIPT_VERSION is older, so there's a newer version available
log "⚠ New version available: $latest_version"
notify "mover-tuning-start.sh Update" "Version $latest_version available (current: $SCRIPT_VERSION)
📖 Visit the TRaSH-Guides for the latest version"
else
# latest_version is older, local version is newer
log "✓ Local version ($SCRIPT_VERSION) is newer than remote ($latest_version)"
fi
else
log "✓ Script is up to date"
fi
return 0
}
check_config_version() {
log "Checking for config file updates..."
# Check if version check is enabled
if [[ "${ENABLE_VERSION_CHECK:-true}" != "true" ]]; then
log "Config version check disabled"
return 0
fi
# Check for curl or wget
local fetch_cmd
if command -v curl &> /dev/null; then
fetch_cmd="curl -s"
elif command -v wget &> /dev/null; then
fetch_cmd="wget -qO-"
else
log "⚠ Cannot check config version: curl or wget not found (continuing anyway)"
return 0
fi
# Fetch the latest config from GitHub
local remote_config
remote_config=$($fetch_cmd "$CONFIG_RAW_URL" 2>/dev/null) || true
if [[ -z "$remote_config" ]]; then
log "⚠ Could not fetch latest config from GitHub (continuing anyway)"
return 0
fi
# Extract version from the remote config
local remote_config_version
remote_config_version=$(echo "$remote_config" | grep -m1 "^readonly CONFIG_VERSION=" | sed 's/readonly CONFIG_VERSION="\([^"]*\)".*/\1/' 2>/dev/null) || true
if [[ -z "$remote_config_version" ]]; then
log "⚠ Could not parse version from remote config (continuing anyway)"
return 0
fi
# Get current config version (handle case where it might not be set)
local current_config_version="${CONFIG_VERSION:-unknown}"
log "Current config version: $current_config_version"
log "Latest config version: $remote_config_version"
# Compare versions
if [[ "$current_config_version" != "$remote_config_version" ]]; then
# Simple version comparison (works for semantic versioning)
# Sort both versions and check if current_config_version comes first (is older)
local oldest_version
oldest_version=$(printf '%s\n' "$remote_config_version" "$current_config_version" | sort -V | head -n1)
if [[ "$oldest_version" == "$current_config_version" ]]; then
# current_config_version is older, so there's a newer version available
log "⚠ New config version available: $remote_config_version"
notify "mover-tuning.cfg Update" "Config version $remote_config_version available
Current version: $current_config_version
📖 Visit the TRaSH-Guides for the latest version"
else
# remote_config_version is older, local version is newer
log "✓ Local config version ($current_config_version) is newer than remote ($remote_config_version)"
fi
else
log "✓ Config is up to date"
fi
return 0
}
check_mover_version() {
log "Checking for mover.py updates..."
# Check if version check is enabled
if [[ "${ENABLE_VERSION_CHECK:-true}" != "true" ]]; then
log "Mover version check disabled"
return 0
fi
# Check if mover.py exists locally
if [[ ! -f "$MOVER_SCRIPT" ]]; then
log "⚠ mover.py not found locally, skipping version check"
return 0
fi
# Check for required commands
if ! command -v md5sum &> /dev/null && ! command -v sha256sum &> /dev/null; then
log "⚠ Cannot check mover.py: md5sum or sha256sum not found (continuing anyway)"
return 0
fi
# Check for curl or wget
local fetch_cmd
if command -v curl &> /dev/null; then
fetch_cmd="curl -s"
elif command -v wget &> /dev/null; then
fetch_cmd="wget -qO-"
else
log "⚠ Cannot check mover version: curl or wget not found (continuing anyway)"
return 0
fi
# Fetch the latest mover.py from GitHub
local remote_mover
remote_mover=$($fetch_cmd "$MOVER_URL" 2>/dev/null) || true
if [[ -z "$remote_mover" ]]; then
log "⚠ Could not fetch latest mover.py from GitHub (continuing anyway)"
return 0
fi
# Calculate hashes (normalize line endings and trailing newlines)
# Strip \r and trailing newlines to ensure consistent comparison
local local_hash remote_hash local_content remote_content
# Read and normalize local file
local_content=$(tr -d '\r' < "$MOVER_SCRIPT")
# Remove trailing newlines by using parameter expansion
local_content="${local_content%$'\n'}"
# Normalize remote content
remote_content=$(printf '%s' "$remote_mover" | tr -d '\r')
remote_content="${remote_content%$'\n'}"
if command -v sha256sum &> /dev/null; then
local_hash=$(printf '%s' "$local_content" | sha256sum | awk '{print $1}')
remote_hash=$(printf '%s' "$remote_content" | sha256sum | awk '{print $1}')
else
local_hash=$(printf '%s' "$local_content" | md5sum | awk '{print $1}')
remote_hash=$(printf '%s' "$remote_content" | md5sum | awk '{print $1}')
fi
log "Local hash: $local_hash"
log "Remote hash: $remote_hash"
# Compare hashes
if [[ "$local_hash" != "$remote_hash" ]]; then
log "⚠ mover.py differs from GitHub version"
# Additional diagnostics
local local_size remote_size
local_size=$(wc -c < "$MOVER_SCRIPT")
remote_size=$(printf '%s' "$remote_mover" | wc -c)
log "Local size: $local_size bytes"
log "Remote size: $remote_size bytes"
notify "mover.py Update" "A newer version of mover.py is available on GitHub
📖 Delete mover.py and re-run script to update"
else
log "✓ mover.py is up to date"
fi
return 0
}
# ================================
# AUTO INSTALLER FUNCTION
# ================================
run_auto_installer() {
log "========================================"
log "Running qBit-Api and qBit-Mover Auto Installer"
log "========================================"
# Create QBIT_MOVER_PATH directory if needed
if [[ ! -d "$QBIT_MOVER_PATH" ]]; then
mkdir -p "$QBIT_MOVER_PATH" || error "Failed to create $QBIT_MOVER_PATH"
set_ownership "$QBIT_MOVER_PATH"
log "✓ Created $QBIT_MOVER_PATH"
fi
# Create virtual environment if needed
if [[ ! -d "$VENV_PATH" ]]; then
log "Creating virtual environment..."
python3 -m venv "$VENV_PATH" || error "Failed to create virtual environment"
set_ownership "$VENV_PATH"
log "✓ Virtual environment created"
else
log "✓ Virtual environment exists"
fi
# Activate virtual environment
# shellcheck source=/dev/null
source "${VENV_PATH}/bin/activate" || error "Failed to activate virtual environment"
# Upgrade pip if needed
log "Checking pip version..."
if pip3 install --upgrade pip --quiet 2>&1 | grep -q "Successfully installed"; then
log "✓ Pip upgraded to $(pip3 --version | awk '{print $2}')"
set_ownership "$VENV_PATH"
else
log "✓ Pip is up to date"
fi
# Install/upgrade qbittorrent-api
if python3 -c "import qbittorrentapi" 2>/dev/null; then
log "✓ qbittorrent-api installed ($(pip3 show qbittorrent-api 2>/dev/null | awk '/Version:/ {print $2}'))"
# Check for updates
if pip3 install --dry-run --upgrade qbittorrent-api 2>&1 | grep -q "Would install"; then
log "Upgrading qbittorrent-api..."
pip3 install qbittorrent-api --upgrade --quiet || log "⚠ Warning: Failed to upgrade qbittorrent-api"
set_ownership "$VENV_PATH"
log "✓ qbittorrent-api upgraded"
else
log "✓ qbittorrent-api is up to date"
fi
else
log "Installing qbittorrent-api..."
pip3 install qbittorrent-api --quiet || error "Failed to install qbittorrent-api"
set_ownership "$VENV_PATH"
log "✓ qbittorrent-api installed"
fi
deactivate
# Download mover.py if needed
if [[ ! -f "$MOVER_SCRIPT" ]]; then
log "Downloading mover.py..."
if curl -sSL "$MOVER_URL" -o "$MOVER_SCRIPT"; then
chmod +x "$MOVER_SCRIPT"
set_ownership "$MOVER_SCRIPT"
log "✓ mover.py downloaded"
else
error "Failed to download mover.py"
fi
else
log "✓ mover.py exists"
fi
log "========================================"
log "Auto Installer completed"
log "========================================"
}
# ================================
# VALIDATION
# ================================
validate_config() {
log "Validating configuration..."
# Check required commands
for cmd in python3 date curl; do
check_command "$cmd" || error "$cmd is not installed"
done
# Validate docker if needed
if [[ "$ENABLE_QBIT_MANAGE" == true ]]; then
check_command docker || error "docker is required when ENABLE_QBIT_MANAGE=true"
fi
# Validate settings
[[ "$DAYS_FROM" -ge 2 ]] || error "DAYS_FROM must be at least 2"
[[ "$DAYS_TO" -ge "$DAYS_FROM" ]] || error "DAYS_TO must be >= DAYS_FROM"
[[ -d "$CACHE_MOUNT" ]] || error "Cache mount does not exist: $CACHE_MOUNT"
# Validate instance configuration
local format
format=$(detect_config_format)
if [[ "$format" == "array" ]]; then
# Validate array-based config
if [[ ${#HOSTS[@]} -eq 0 ]]; then
notify "Configuration Error" "HOSTS array is empty"
error "HOSTS array is empty"
fi
if [[ ${#USERS[@]} -ne ${#HOSTS[@]} ]]; then
notify "Configuration Error" "USERS array length (${#USERS[@]}) doesn't match HOSTS (${#HOSTS[@]})"
error "USERS array length doesn't match HOSTS"
fi
if [[ ${#PASSWORDS[@]} -ne ${#HOSTS[@]} ]]; then
notify "Configuration Error" "PASSWORDS array length (${#PASSWORDS[@]}) doesn't match HOSTS (${#HOSTS[@]})"
error "PASSWORDS array length doesn't match HOSTS"
fi
# NAMES array is optional, but if present should match
if [[ -v NAMES[@] ]] && [[ ${#NAMES[@]} -gt 0 ]]; then
if [[ ${#NAMES[@]} -ne ${#HOSTS[@]} ]]; then
notify "Configuration Error" "NAMES array length (${#NAMES[@]}) doesn't match HOSTS (${#HOSTS[@]})"
error "NAMES array length doesn't match HOSTS"
fi
fi
log "✓ Using array-based configuration (${#HOSTS[@]} instance(s))"
else
# Validate legacy config
[[ -n "${QBIT_HOST_1:-}" ]] || error "QBIT_HOST_1 is not set"
[[ -n "${QBIT_USER_1:-}" ]] || error "QBIT_USER_1 is not set"
[[ -n "${QBIT_PASS_1:-}" ]] || error "QBIT_PASS_1 is not set"
log "✓ Using legacy configuration"
fi
}
# ================================
# PROCESS QBITTORRENT INSTANCE
# ================================
process_qbit_instance() {
local name="$1" host="$2" user="$3" password="$4"
log "Processing $name..."
# Determine Python command
local python_cmd
if [[ -f "${VENV_PATH}/bin/python3" ]]; then
python_cmd="${VENV_PATH}/bin/python3"
elif python3 -c "import qbittorrentapi" 2>/dev/null; then
python_cmd="python3"
else
log "✗ qbittorrent-api not found for $name"
return 1
fi
# Run mover script
if $python_cmd "$MOVER_SCRIPT" \
--pause \
--host "$host" \
--user "$user" \
--password "$password" \
--cache-mount "$CACHE_MOUNT" \
--days_from "$DAYS_FROM" \
--days_to "$DAYS_TO" 2>&1 | while IFS= read -r line; do
log " $line"
done; then
log "✓ Successfully processed $name"
notify "$name" "Paused @ $(date +%H:%M:%S)"
return 0
else
log "✗ Failed to process $name"
return 1
fi
}
# ================================
# MAIN EXECUTION
# ================================
main() {
local failed_instances=0
readonly date_from=$(date --date="$DAYS_FROM day ago" +%F)
log "========================================"
log "qBittorrent Cache Mover Started"
log "Date range: $DAYS_FROM-$DAYS_TO days (from $date_from)"
log "========================================"
# Check for script updates
check_script_version
# Check for config updates
check_config_version
# Check for mover.py updates
check_mover_version
# Run auto installer if enabled
[[ "$ENABLE_AUTO_INSTALLER" == true ]] && run_auto_installer
# Validate configuration
validate_config
[[ -f "$MOVER_SCRIPT" ]] || error "mover.py not found at: $MOVER_SCRIPT"
# Stop qBit-Manage if enabled
if [[ "$ENABLE_QBIT_MANAGE" == true ]]; then
log "Stopping $QBIT_MANAGE_CONTAINER..."
if docker stop "$QBIT_MANAGE_CONTAINER" &> /dev/null; then
log "✓ Stopped qBit-Manage"
notify "qBit-Manage" "Stopped @ $(date +%H:%M:%S)"
sleep "$QBIT_MANAGE_WAIT"
else
log "⚠ Warning: Failed to stop $QBIT_MANAGE_CONTAINER"
fi
fi
# Process all instances
local instance_count
instance_count=$(get_instance_count)
for ((i=0; i