#!/bin/bash # Torrent Mover v7.2 - Enhanced & Robust Version with Directory Deduplication, # Improved Archive Handling (keeping archives until ratio limits are reached) # # 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. # # Future improvements might include using Transmission’s RPC API. ############################## # 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; } ############################## # Global Runtime Variables # ############################## DRY_RUN=0 INTERACTIVE=0 CACHE_WARMUP=0 DEBUG=0 # Set to 1 if LOG_LEVEL is DEBUG or --debug is passed # To avoid reprocessing the same source directory (across different torrents) declare -A processed_source_dirs #################### # Logging Functions# #################### # All log messages go to stderr. log_debug() { if [[ "${DEBUG}" -eq 1 ]]; then echo -e "[DEBUG] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 [[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[DEBUG] $*" fi } log_info() { echo -e "[INFO] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 [[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[INFO] $*" } log_warn() { echo -e "[WARN] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 [[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[WARN] $*" } log_error() { echo -e "[ERROR] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 [[ "${USE_SYSLOG}" == "true" ]] && logger -t torrent-mover "[ERROR] $*" } ################################# # Error Handling & Notifications# ################################# error_handler() { local lineno="$1" local msg="$2" log_error "Error on line ${lineno}: ${msg}" # Optionally send a notification (e.g., email) exit 1 } trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR ############################## # 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 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 ############################## # Helper & Utility Functions # ############################## # translate_source: Converts the Transmission‑reported path into the local path. translate_source() { local src="$1" echo "${src/#${TRANSMISSION_PATH_PREFIX}/${LOCAL_PATH_PREFIX}}" } # parse_args: Processes command‑line options. parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --interactive) INTERACTIVE=1; shift ;; --cache-warmup) CACHE_WARMUP=1; shift ;; --debug) DEBUG=1; shift ;; --help) echo "Usage: $0 [--dry-run] [--interactive] [--cache-warmup] [--debug]" >&2 exit 0 ;; *) echo "Invalid option: $1" >&2; exit 1 ;; esac done } # check_dependencies: Ensures required commands are available. check_dependencies() { local deps=("transmission-remote" "unrar" "unzip" "7z" "parallel" "bc") for dep in "${deps[@]}"; do command -v "${dep}" >/dev/null 2>&1 || { log_error "Missing dependency: ${dep}"; exit 1; } done } # check_disk_usage: Warn if disk usage is over 90%. declare -A CHECKED_MOUNTS=() check_disk_usage() { local dir="$1" [[ -z "${dir}" ]] && return if ! df -P "${dir}" &>/dev/null; then log_warn "Directory not found: ${dir}" return fi local mount_point mount_point=$(df -P "${dir}" | awk 'NR==2 {print $6}') [[ -z "${mount_point}" ]] && return if [[ -z "${CHECKED_MOUNTS["${mount_point}"]+x}" ]]; then local usage usage=$(df -P "${dir}" | awk 'NR==2 {sub(/%/, "", $5); print $5}') if (( usage >= 90 )); then log_warn "Storage warning: ${mount_point} at ${usage}% capacity" fi CHECKED_MOUNTS["${mount_point}"]=1 fi } # init_checksum_db: Initializes the checksum database. init_checksum_db() { mkdir -p "$(dirname "${CHECKSUM_DB}")" touch "${CHECKSUM_DB}" || { log_error "Could not create ${CHECKSUM_DB}"; exit 1; } chmod 600 "${CHECKSUM_DB}" } # record_checksums: Generates checksums for files in given directories. record_checksums() { log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads" find "$@" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \ parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} md5sum | sort > "${CHECKSUM_DB}.tmp" mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}" } # file_metadata: Returns an md5 hash for file metadata. file_metadata() { find "$1" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort | awk '{print $1}' } # files_need_processing: Checks if the source files need processing. files_need_processing() { local src="$1" shift local targets=("$@") if [[ ! -d "${src}" ]]; then log_warn "Source directory missing: ${src}" return 1 fi log_info "=== FILE VERIFICATION DEBUG START ===" log_info "Source directory: ${src}" log_info "Verification targets: ${targets[*]}" local empty_target_found=0 for target in "${targets[@]}"; do if [[ ! -d "${target}" ]]; then log_info "Target missing: ${target}" empty_target_found=1 continue fi local file_count file_count=$(find "${target}" -mindepth 1 -maxdepth 1 -print | wc -l) log_debug "File count for target ${target}: ${file_count}" if [[ "${file_count}" -eq 0 ]]; then log_info "Empty target directory: ${target}" empty_target_found=1 else log_info "Target contains ${file_count} items: ${target}" log_info "First 5 items:" find "${target}" -mindepth 1 -maxdepth 1 | head -n 5 | while read -r item; do log_info " - ${item##*/}" done fi done if [[ "${empty_target_found}" -eq 1 ]]; then log_info "Empty target detected - processing needed" log_info "=== FILE VERIFICATION DEBUG END ===" return 0 fi log_info "Generating source checksums..." local src_checksums src_checksums=$(find "${src}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort) log_info "First 5 source checksums:" echo "${src_checksums}" | head -n 5 | while read -r line; do log_info " ${line}" done local match_found=0 for target in "${targets[@]}"; do log_info "Checking against target: ${target}" log_info "Generating target checksums..." local target_checksums target_checksums=$(find "${target}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort) log_info "First 5 target checksums:" echo "${target_checksums}" | head -n 5 | while read -r line; do log_info " ${line}" done if diff <(echo "${src_checksums}") <(echo "${target_checksums}") >/dev/null; then log_info "Exact checksum match found in: ${target}" match_found=1 break else log_info "No match in: ${target}" fi done log_info "=== FILE VERIFICATION DEBUG END ===" [[ "${match_found}" -eq 1 ]] && return 1 || return 0 } # warm_cache: Pre-calculates checksums for storage directories. warm_cache() { log_info "Starting cache warmup for Movies..." local targets=("${DIR_MOVIES_DST}" "${STORAGE_DIRS_ARRAY[@]}") record_checksums "${targets[@]}" log_info "Cache warmup completed. Checksums stored in ${CHECKSUM_DB}" } # is_processed: Checks if the torrent (by hash) has already been processed. is_processed() { grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null } # mark_processed: Records a processed torrent. mark_processed() { echo "${1}" >> "${PROCESSED_LOG}" } # get_destination: Maps a source directory to a destination directory based on keywords. declare -A PATH_CACHE get_destination() { local source_path="$1" if [[ -n "${PATH_CACHE["${source_path}"]+x}" ]]; then echo "${PATH_CACHE["${source_path}"]}" return fi log_info "Analyzing path: ${source_path}" local destination case "${source_path,,}" in *games*) destination="${DIR_GAMES_DST}";; *apps*) destination="${DIR_APPS_DST}";; *movies*) destination="${DIR_MOVIES_DST}";; *books*) destination="${DIR_BOOKS_DST}";; *) destination="${DEFAULT_DST}";; esac log_info "Mapped to: ${destination}" PATH_CACHE["${source_path}"]="${destination}" echo "${destination}" } ###################################### # Improved Archive Extraction Handler # ###################################### # For each archive found in the source directory, create a subdirectory in the destination # named after the archive (without its extension) and extract into that subdirectory. # IMPORTANT: The archive is now retained in the source, so it will remain until the ratio # limits are reached and Transmission removes the torrent data. handle_archives() { local src="$1" dst="$2" find "${src}" -type f \( -iname "*.rar" -o -iname "*.zip" -o -iname "*.7z" \) | while read -r arch; do log_info "Extracting archive: ${arch}" local base base=$(basename "${arch}") local subdir="${dst}/${base%.*}" mkdir -p "${subdir}" || { log_error "Failed to create subdirectory ${subdir}"; continue; } case "${arch##*.}" in rar) unrar x -o- "${arch}" "${subdir}" || { log_error "unrar failed for ${arch}"; continue; } ;; zip) unzip -o "${arch}" -d "${subdir}" || { log_error "unzip failed for ${arch}"; continue; } ;; 7z) 7z x "${arch}" -o"${subdir}" || { log_error "7z extraction failed for ${arch}"; continue; } ;; esac log_info "Archive ${arch} retained in source until ratio limits are reached." done } # move_files: Moves files using parallel processing if enabled. move_files() { if (( PARALLEL_PROCESSING )); then parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/* else mv "${2}"/* "${1}" fi } # copy_files: Copies files using parallel processing if enabled. copy_files() { if (( PARALLEL_PROCESSING )); then parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} "${1}" ::: "${2}"/* else cp -r "${2}"/* "${1}" fi } # process_copy: Validates directories, then copies/moves files from source to destination. # Optionally verifies integrity after transfer if CHECK_TRANSFER_INTEGRITY is "true". process_copy() { local id="$1" hash="$2" src="$3" dst="$4" if [[ ! -d "${src}" ]]; then log_error "Source directory missing: ${src}" return 1 fi if [[ ! -d "${dst}" ]]; then log_info "Creating destination directory: ${dst}" mkdir -p "${dst}" || { log_error "Failed to create directory: ${dst}"; return 1; } chmod 775 "${dst}" chown debian-transmission:debian-transmission "${dst}" fi if [[ ! -w "${dst}" ]]; then log_error "No write permissions for: ${dst}" return 1 fi if (( DRY_RUN )); then log_info "[DRY RUN] Would process torrent ${id}:" log_info " - Copy files from ${src} to ${dst}" log_info " - File count: $(find "${src}" -maxdepth 1 -type f | wc -l)" return fi handle_archives "${src}" "${dst}" case "${COPY_MODE}" in move) log_info "Moving files from ${src} to ${dst}" move_files "${dst}" "${src}" ;; copy) log_info "Copying files from ${src} to ${dst}" copy_files "${dst}" "${src}" ;; esac if [ $? -eq 0 ]; then if [[ "${CHECK_TRANSFER_INTEGRITY}" == "true" ]]; then log_info "Verifying integrity of transferred files..." local src_checksum target_checksum src_checksum=$(find "${src}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort) target_checksum=$(find "${dst}" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort) if diff <(echo "${src_checksum}") <(echo "${target_checksum}") >/dev/null; then log_info "Integrity check passed." else log_error "Integrity check FAILED for ${src}" return 1 fi fi log_info "Transfer completed successfully" mark_processed "${hash}" else log_error "Transfer failed for ${src}" fi } # process_removal: Removes a torrent via Transmission. process_removal() { local id="$1" if (( DRY_RUN )); then log_info "[DRY RUN] Would remove torrent ${id}" return fi transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" \ -t "${id}" --remove-and-delete } ################# # 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}" ) for dir in "${REQUIRED_DIRS[@]}"; do if [[ ! -d "${dir}" ]]; then log_error "Directory missing: ${dir}" exit 1 fi if [[ ! -w "${dir}" ]]; then log_error "Write permission denied: ${dir}" exit 1 fi done init_checksum_db if (( CACHE_WARMUP )); then warm_cache exit 0 fi log_info "Starting processing" declare -A warned_dirs=() transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -l | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1}' | while read -r id; do local info info=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -t "${id}" -i) 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) local dir dir=$(translate_source "${reported_dir}") log_info "Torrent source directory reported: '${reported_dir}' translated to '${dir}'" local dst dst=$(get_destination "${dir}") [[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0 # 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}" ]] && (( 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[@]}");; 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 check_disk_usage "${DIR_GAMES_DST}" check_disk_usage "${DIR_APPS_DST}" check_disk_usage "${DIR_MOVIES_DST}" check_disk_usage "${DIR_BOOKS_DST}" check_disk_usage "${DEFAULT_DST}" } ###################### # Command-line Parsing # ###################### parse_args "$@" if (( INTERACTIVE )); then read -rp "Confirm processing? (y/n) " choice [[ "${choice}" =~ ^[Yy]$ ]] || exit 0 fi main