#!/bin/bash set -euo pipefail # Exit on error, undefined variables, and pipe failures # ===================================== # Script: qBittorrent Cache Mover - End # Version: 1.1.0 # Updated: 20251201 # ===================================== # Script version and update check URLs readonly SCRIPT_VERSION="1.1.0" readonly SCRIPT_RAW_URL="https://raw.githubusercontent.com/TRaSH-Guides/Guides/refs/heads/master/includes/downloaders/mover-tuning-end.sh" # 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" # 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 } # ================================ # 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-end.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 } # ================================ # AUTO-INSTALLER FUNCTIONS # ================================ install_fclones_binary() { log "Installing/updating fclones binary..." # fclones configuration local FCLONES_BIN="/usr/local/bin/fclones" local BOOT_DIR="/boot/config/plugins/fclones/usr/bin" local GO_FILE="/boot/config/go" local DEFAULT_VERSION="0.35.0" # Current installed version local CURRENT_VERSION="" if [[ -x "$FCLONES_BIN" ]]; then CURRENT_VERSION=$($FCLONES_BIN --version 2>/dev/null | awk '{print $2}') || true log "✓ Found fclones version $CURRENT_VERSION" else log "✗ fclones not found" fi # Check for curl or wget local GITHUB_API_CMD if command -v curl >/dev/null 2>&1; then GITHUB_API_CMD="curl -s https://api.github.com/repos/pkolaczk/fclones/releases/latest" elif command -v wget >/dev/null 2>&1; then GITHUB_API_CMD="wget -qO- https://api.github.com/repos/pkolaczk/fclones/releases/latest" else log "✗ Neither curl nor wget is available (continuing anyway)" return 0 fi # Fetch latest release from GitHub local LATEST_VERSION LATEST_VERSION=$($GITHUB_API_CMD 2>/dev/null | grep -Po '"tag_name": "\K.*?(?=")') || true if [[ -z "$LATEST_VERSION" ]]; then log "⚠ Could not fetch latest release, using default version $DEFAULT_VERSION (continuing anyway)" LATEST_VERSION="$DEFAULT_VERSION" else log "Latest fclones release: $LATEST_VERSION" fi # Compare and install if missing or outdated if [[ "$CURRENT_VERSION" != "$LATEST_VERSION" ]]; then log "Installing/updating fclones to $LATEST_VERSION..." local TMP_DIR TMP_DIR=$(mktemp -d) # Remove leading 'v' from filename local VERSION_NO_V="${LATEST_VERSION#v}" local DOWNLOAD_URL="https://github.com/pkolaczk/fclones/releases/download/$LATEST_VERSION/fclones-$VERSION_NO_V-linux-glibc-x86_64.tar.gz" if ! wget -O "$TMP_DIR/fclones.tar.gz" "$DOWNLOAD_URL" 2>/dev/null; then log "✗ Failed to download fclones from $DOWNLOAD_URL (continuing anyway)" rm -rf "$TMP_DIR" return 0 fi if ! tar -xzf "$TMP_DIR/fclones.tar.gz" -C "$TMP_DIR" 2>/dev/null; then log "✗ Failed to extract fclones archive (continuing anyway)" rm -rf "$TMP_DIR" return 0 fi mkdir -p "$BOOT_DIR" || true cp "$TMP_DIR/usr/bin/fclones" "$BOOT_DIR/fclones" 2>/dev/null || true chmod +x "$BOOT_DIR/fclones" 2>/dev/null || true # Copy to /usr/local/bin immediately cp "$BOOT_DIR/fclones" /usr/local/bin/fclones 2>/dev/null || true chmod +x /usr/local/bin/fclones 2>/dev/null || true # Add boot-time copy and PATH setup if not already in /boot/config/go if ! grep -q "fclones boot-time setup" "$GO_FILE" 2>/dev/null; then if [ -w "$GO_FILE" ]; then echo "" >> "$GO_FILE" echo "# fclones boot-time setup" >> "$GO_FILE" echo "export PATH=/usr/local/bin:\$PATH" >> "$GO_FILE" echo "cp $BOOT_DIR/fclones /usr/local/bin/fclones" >> "$GO_FILE" else log "⚠ Cannot write to $GO_FILE. Please check permissions. (continuing anyway)" fi fi rm -rf "$TMP_DIR" log "✓ fclones $VERSION_NO_V installed successfully" return 0 else log "✓ fclones is up to date ($CURRENT_VERSION)" return 0 fi } install_fclones_script() { log "Installing fclones.sh script..." local raw_script_url="https://gist.githubusercontent.com/BaukeZwart/b570ce6b6165c4f0b64c5b98d9d3af1e/raw" local script_path="${QBIT_MOVER_PATH}fclones.sh" # Create directory if needed mkdir -p "$QBIT_MOVER_PATH" # Download script if command -v curl &> /dev/null; then if ! curl -fsSL "$raw_script_url" -o "$script_path" 2>/dev/null; then log "✗ Failed to download fclones.sh script" return 1 fi elif command -v wget &> /dev/null; then if ! wget -q "$raw_script_url" -O "$script_path" 2>/dev/null; then log "✗ Failed to download fclones.sh script" return 1 fi else log "✗ Neither curl nor wget is available" return 1 fi # Set permissions chmod +x "$script_path" chown nobody:users "$script_path" 2>/dev/null || \ log "⚠ Warning: Could not set ownership to nobody:users" log "✓ fclones.sh script installed at $script_path" return 0 } check_and_install_fclones() { log "Checking fclones installation..." local need_install=false # Check binary if ! command -v fclones &> /dev/null; then log "✗ fclones binary not found" if ! install_fclones_binary; then notify "fclones Auto-Installer" "Failed to install binary @ $(date +%H:%M:%S)" return 1 fi need_install=true else # Check if update is needed if ! install_fclones_binary; then log "⚠ Failed to check/update fclones binary (continuing anyway)" fi fi # Check script if [[ ! -f "${QBIT_MOVER_PATH}fclones.sh" ]]; then log "✗ fclones.sh script not found" if ! install_fclones_script; then notify "fclones Auto-Installer" "Failed to install script @ $(date +%H:%M:%S)" return 1 fi need_install=true else log "✓ fclones.sh script found" fi if [[ "$need_install" == true ]]; then log "✓ fclones installation completed" notify "fclones Auto-Installer" "Installation completed @ $(date +%H:%M:%S)" fi return 0 } # ================================ # VALIDATION # ================================ validate_config() { log "Validating configuration..." # Check required commands local missing_cmds=() for cmd in python3 date; do command -v "$cmd" &> /dev/null || missing_cmds+=("$cmd") done if [[ ${#missing_cmds[@]} -gt 0 ]]; then error "Missing required commands: ${missing_cmds[*]}" fi # Check docker if needed if [[ "$ENABLE_QBIT_MANAGE" == true ]] && ! command -v docker &> /dev/null; then error "docker is required when ENABLE_QBIT_MANAGE=true" fi # Validate paths and values [[ "$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 not found: $CACHE_MOUNT" [[ -f "${QBIT_MOVER_PATH}mover.py" ]] || error "mover.py not found: ${QBIT_MOVER_PATH}mover.py" # Validate instance configuration local format format=$(detect_config_format) if [[ "$format" == "array" ]]; then # Validate array-based config [[ ${#HOSTS[@]} -gt 0 ]] || error "HOSTS array is empty" [[ ${#USERS[@]} -eq ${#HOSTS[@]} ]] || error "USERS array length doesn't match HOSTS" [[ ${#PASSWORDS[@]} -eq ${#HOSTS[@]} ]] || error "PASSWORDS array length doesn't match HOSTS" # NAMES array is optional, but if present should match if [[ -v NAMES[@] ]] && [[ ${#NAMES[@]} -gt 0 ]]; then [[ ${#NAMES[@]} -eq ${#HOSTS[@]} ]] || error "NAMES array length doesn't match HOSTS" 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 # Validate duplicate finder if enabled if [[ "$ENABLE_DUPLICATE_FINDER" == true ]]; then if [[ "$ENABLE_AUTO_INSTALLER" == true ]]; then check_and_install_fclones || return 1 else [[ -f "${QBIT_MOVER_PATH}fclones.sh" ]] || \ error "Duplicate finder script not found: ${QBIT_MOVER_PATH}fclones.sh" [[ -x "${QBIT_MOVER_PATH}fclones.sh" ]] || \ error "Duplicate finder script not executable: ${QBIT_MOVER_PATH}fclones.sh" fi fi log "✓ Validation completed" } # ================================ # PROCESS QBITTORRENT INSTANCE # ================================ process_qbit_instance() { local name="$1" local host="$2" local user="$3" local password="$4" log "Processing $name..." # Determine Python command local python_cmd if [[ -f "${QBIT_MOVER_PATH}.venv/bin/python3" ]]; then python_cmd="${QBIT_MOVER_PATH}.venv/bin/python3" log "✓ Using virtual environment" elif python3 -c "import qbittorrentapi" 2>/dev/null; then python_cmd="python3" log "✓ Using system Python" else log "✗ qbittorrent-api not found" return 1 fi # Execute mover script if "$python_cmd" "${QBIT_MOVER_PATH}mover.py" \ --resume \ --host "$host" \ --user "$user" \ --password "$password" \ --days_from "$DAYS_FROM" \ --days_to "$DAYS_TO"; then log "✓ Successfully resumed torrents for $name" notify "$name" "Resumed @ $(date +%H:%M:%S)" return 0 else log "✗ Failed to resume torrents for $name" return 1 fi } # ================================ # MAIN EXECUTION # ================================ main() { local failed_instances=0 local date_str date_str=$(date --date="$DAYS_FROM day ago" +%F) log "========================================" log "Starting torrent resume process" log "Age range: $DAYS_FROM-$DAYS_TO days (from $date_str)" log "========================================" # Check for script updates check_script_version # Validate configuration validate_config || exit 1 # Process all instances local instance_count instance_count=$(get_instance_count) for ((i=0; i /dev/null; then log "✓ qBit-Manage started" notify "qBit-Manage" "Started @ $(date +%H:%M:%S)" else log "⚠ Failed to start qBit-Manage" fi fi # Summary log "========================================" if [[ $failed_instances -eq 0 ]]; then log "✓ All operations completed successfully" exit 0 else log "⚠ Completed with $failed_instances failed instance(s)" exit 1 fi } # Run main function main