#!/bin/bash # Torrent Mover v8.0 - Enhanced & Robust Version with modular architecture, # improved error handling, security, and content categorization # # This script processes completed torrents reported by Transmission, # moving or copying files to designated destination directories. # It includes robust locking, advanced error handling & notifications, # improved logging, optional post-transfer integrity checks, configurable path mapping, # and improved archive extraction that preserves directory structure. # Set script location for importing modules SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" LIB_DIR="/usr/local/lib/torrent-mover" ############################## # Robust Locking with flock # ############################## LOCK_FILE="/var/lock/torrent-mover.lock" exec 200>"${LOCK_FILE}" || { echo "Cannot open lock file" >&2; exit 1; } flock -n 200 || { echo "Another instance is running." >&2; exit 1; } ############################## # Configuration & Validation # ############################## CONFIG_FILE="/etc/torrent/mover.conf" if [[ ! -f "${CONFIG_FILE}" ]]; then echo "FATAL: Configuration file missing: ${CONFIG_FILE}" >&2 exit 1 fi source "${CONFIG_FILE}" # Validate required configuration values. if [[ -z "${TRANSMISSION_PATH_PREFIX:-}" || -z "${LOCAL_PATH_PREFIX:-}" ]]; then echo "FATAL: TRANSMISSION_PATH_PREFIX and LOCAL_PATH_PREFIX must be set in ${CONFIG_FILE}" >&2 exit 1 fi # Load modules for module in "${LIB_DIR}"/*.sh; do if [[ -f "$module" ]]; then source "$module" fi done # Set defaults for new configuration options TORRENT_USER="${TORRENT_USER:-debian-transmission}" TORRENT_GROUP="${TORRENT_GROUP:-debian-transmission}" MAX_RETRY_ATTEMPTS="${MAX_RETRY_ATTEMPTS:-3}" RETRY_WAIT_TIME="${RETRY_WAIT_TIME:-15}" # Enable DEBUG mode if set in config if [[ "${LOG_LEVEL}" == "DEBUG" ]]; then DEBUG=1 fi # Parse STORAGE_DIRS into an array. STORAGE_DIRS_ARRAY=() if [[ -n "${STORAGE_DIRS}" ]]; then IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}" fi ################################# # Error Handling & Notifications# ################################# trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR ################# # Main Function # ################# main() { check_dependencies # Validate destination directories. declare -a REQUIRED_DIRS=( "${DIR_GAMES_DST}" "${DIR_APPS_DST}" "${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" "${DEFAULT_DST}" ) # Add optional directories if defined [[ -n "${DIR_TV_DST}" ]] && REQUIRED_DIRS+=("${DIR_TV_DST}") [[ -n "${DIR_MUSIC_DST}" ]] && REQUIRED_DIRS+=("${DIR_MUSIC_DST}") # Create required directories if they don't exist log_info "Creating required directories if they don't exist..." for dir in "${REQUIRED_DIRS[@]}"; do if [[ -n "$dir" ]]; then if [[ ! -d "$dir" ]]; then log_info "Creating directory: $dir" if mkdir -p "$dir"; then # Try to set permissions but don't fail if it doesn't work chmod 775 "$dir" 2>/dev/null || log_warn "Could not set permissions on $dir" chown ${TORRENT_USER:-debian-transmission}:${TORRENT_GROUP:-debian-transmission} "$dir" 2>/dev/null || log_warn "Could not set ownership on $dir" log_info "Created directory: $dir" else log_error "Failed to create directory: $dir" fi fi fi done # Now validate that all required directories exist and are writable validate_directories "${REQUIRED_DIRS[@]}" || exit 1 init_checksum_db if (( CACHE_WARMUP )); then warm_cache exit 0 fi log_info "Starting processing with user: ${TORRENT_USER}" declare -A warned_dirs=() # Get list of torrents from Transmission log_debug "Getting list of torrents..." local torrent_ids torrent_ids=$(get_torrents) log_debug "Found $(echo "$torrent_ids" | wc -l) torrents" # Use a regular for loop instead of a pipe to while # to avoid the subshell issue that causes processed_source_dirs to be lost readarray -t torrent_ids_array <<< "$torrent_ids" for id in "${torrent_ids_array[@]}"; do # Skip empty IDs if [[ -z "$id" ]]; then log_debug "Skipping empty torrent ID" continue fi log_debug "Processing torrent ID: $id" local info info=$(get_torrent_info "${id}") local hash hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}') local ratio ratio=$(grep "Ratio:" <<< "${info}" | awk '{print $2 == "None" ? 0 : $2}' | tr -cd '0-9.') ratio=${ratio:-0} local time time=$(grep "Seeding Time:" <<< "${info}" | awk '{print $3 == "None" ? 0 : $3}' | tr -cd '0-9.') time=${time:-0} local percent_done percent_done=$(grep "Percent Done:" <<< "${info}" | awk '{gsub(/%/, ""); print $3 == "None" ? 0 : $3}') percent_done=${percent_done:-0} # Extract Transmission-reported directory and translate to local path. local reported_dir reported_dir=$(grep -i "Location:" <<< "${info}" | awk -F": " '{print $2}' | xargs) log_debug "Raw reported directory: '${reported_dir}'" # If the reported directory is empty, try to derive it from the name if [[ -z "${reported_dir}" ]]; then local name name=$(grep -i "Name:" <<< "${info}" | awk -F": " '{print $2}' | xargs) log_debug "Torrent name: '${name}'" # Check if there are labels we can use local labels labels=$(grep -i "Labels:" <<< "${info}" | awk -F": " '{print $2}' | xargs) log_debug "Torrent labels: '${labels}'" if [[ "${labels}" == *"Books"* ]]; then reported_dir="/downloads/Books" elif [[ "${labels}" == *"Movies"* ]]; then reported_dir="/downloads/Movies" elif [[ "${labels}" == *"TV"* ]]; then reported_dir="/downloads/TV" elif [[ "${labels}" == *"Games"* ]]; then reported_dir="/downloads/Games" elif [[ "${labels}" == *"Apps"* ]]; then reported_dir="/downloads/Apps" elif [[ "${labels}" == *"Music"* ]]; then reported_dir="/downloads/Music" else # Default to Other if we can't determine reported_dir="/downloads/Other" fi log_debug "Derived directory from labels: '${reported_dir}'" fi local dir dir=$(translate_source "${reported_dir}") log_info "Torrent source directory: '${reported_dir}' translated to '${dir}'" # Initialize empty directory mapping if needed if [[ -z "$dir" ]]; then log_warn "Empty directory path detected, using default" dir="${LOCAL_PATH_PREFIX}/Other" fi local dst dst=$(get_destination "${dir}") # Detect same-path mappings (different mounts) if [[ "${dir}" != "${dst}" && "${dir}" =~ ^/mnt/dsnas2/ && "${dst}" =~ ^/mnt/dsnas1/ ]]; then local dir_suffix="${dir#/mnt/dsnas2/}" local dst_suffix="${dst#/mnt/dsnas1/}" if [[ "${dir_suffix}" == "${dst_suffix}" ]]; then log_info "Source and destination are the same logical location with different mounts: ${dir_suffix}" mark_processed "${hash}" continue # Skip to next torrent fi fi # Initialize warned_dirs for this directory if needed if [[ -n "${dir}" ]]; then [[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0 fi # Avoid processing the same directory more than once. if [[ -n "${processed_source_dirs["${dir}"]+x}" ]]; then log_info "Directory ${dir} has already been processed; skipping copy for torrent ${id}" elif (( $(bc <<< "${percent_done} >= 100") )) && ! is_processed "${hash}"; then log_info "Processing completed torrent ${id} (${percent_done}% done)" if [[ "${dst}" == "${DEFAULT_DST}" ]] && [[ -n "${dir}" ]] && (( warned_dirs["${dir}"] == 0 )); then log_warn "Using default destination for: ${dir}" warned_dirs["${dir}"]=1 fi local targets=("${dst}") case "${dst}" in "${DIR_MOVIES_DST}") targets+=("${STORAGE_DIRS_ARRAY[@]}") ;; "${DIR_TV_DST}") # If there are TV storage dirs, include them [[ -n "${STORAGE_TV_DIRS}" ]] && IFS=',' read -ra TV_DIRS <<< "${STORAGE_TV_DIRS}" && targets+=("${TV_DIRS[@]}") ;; esac if ! files_need_processing "${dir}" "${targets[@]}"; then log_info "Skipping copy - files already exist in:" for target in "${targets[@]}"; do [[ -d "${target}" ]] && log_info " - ${target}" done else process_copy "${id}" "${hash}" "${dir}" "${dst}" processed_source_dirs["${dir}"]=1 fi fi if (( $(bc <<< "${ratio} >= ${SEED_RATIO}") )) || (( $(bc <<< "${time} >= ${SEED_TIME}") )); then log_info "Removing torrent ${id} (Ratio: ${ratio}, Time: ${time})" process_removal "${id}" fi done # Print count of processed directories if [[ "${DEBUG}" -eq 1 ]]; then log_debug "Processed source directories count: ${#processed_source_dirs[@]}" for dir in "${!processed_source_dirs[@]}"; do log_debug "Processed directory: $dir" done fi # Check disk usage for all directories for dir in "${REQUIRED_DIRS[@]}"; do check_disk_usage "${dir}" done for dir in "${STORAGE_DIRS_ARRAY[@]}"; do check_disk_usage "${dir}" done } ###################### # Command-line Parsing # ###################### parse_args "$@" if (( INTERACTIVE )); then read -rp "Confirm processing? (y/n) " choice [[ "${choice}" =~ ^[Yy]$ ]] || exit 0 fi main