diff --git a/etc/torrent/mover.conf b/etc/torrent/mover.conf index b57e7b3..4cc44b1 100644 --- a/etc/torrent/mover.conf +++ b/etc/torrent/mover.conf @@ -34,6 +34,9 @@ CHECKSUM_DB="/var/lib/torrent/checksums.db" # System settings LOG_FILE="/var/log/torrent_mover.log" +# Logging level: set to "DEBUG" to enable debug messages; otherwise "INFO" +LOG_LEVEL="INFO" + # Auto-create directories mkdir -p "${DIR_GAMES_DST}" "${DIR_APPS_DST}" \ "${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" \ diff --git a/usr/local/bin/torrent-mover b/usr/local/bin/torrent-mover index e75b10f..41dca97 100755 --- a/usr/local/bin/torrent-mover +++ b/usr/local/bin/torrent-mover @@ -1,35 +1,49 @@ #!/bin/bash -# Torrent Mover v7.2 - Final Debugged Version +# Torrent Mover v7.2 - Enhanced Version with Directory Deduplication +# +# This script processes completed torrents reported by Transmission, +# moving or copying files to designated destination directories. +# It includes improved logging (with debug support), error handling, +# configurable path mappings, and avoids re-processing the same source directory. + +############################# +# Global Variables & Config # +############################# -# Singleton pattern implementation LOCK_FILE="/var/lock/torrent-mover.lock" MAX_AGE=300 # 5 minutes in seconds -# Check for existing lock -if [ -f "${LOCK_FILE}" ]; then - PID=$(cat "${LOCK_FILE}") - if ps -p "${PID}" >/dev/null 2>&1; then - echo "torrent-mover already running (PID: ${PID}), exiting." - exit 1 - else - LOCK_AGE=$(($(date +%s) - $(stat -c %Y "${LOCK_FILE}"))) - if [ "${LOCK_AGE}" -lt "${MAX_AGE}" ]; then - echo "Recent crash detected, waiting..." - exit 1 - fi - echo "Removing stale lock (PID: ${PID})" - rm -f "${LOCK_FILE}" +# Runtime flags (default values) +DRY_RUN=0 +INTERACTIVE=0 +CACHE_WARMUP=0 +DEBUG=0 # Will be set to 1 if LOG_LEVEL is DEBUG or if --debug is passed + +# Associative array to track source directories that have been processed. +declare -A processed_source_dirs + +#################### +# Logging Functions# +#################### +# All log output goes to stderr so that command outputs remain clean. +log_debug() { + if [[ "${DEBUG}" -eq 1 ]]; then + echo -e "[DEBUG] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 fi -fi +} +log_info() { + echo -e "[INFO] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 +} +log_warn() { + echo -e "[WARN] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 +} +log_error() { + echo -e "[ERROR] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2 +} -echo $$ > "${LOCK_FILE}" -trap 'rm -f "${LOCK_FILE}"' EXIT TERM INT - -set -o errexit -set -o nounset -set -o pipefail - -# Load configuration +############################## +# Configuration & Validation # +############################## CONFIG_FILE="/etc/torrent/mover.conf" if [[ ! -f "${CONFIG_FILE}" ]]; then echo "FATAL: Configuration file missing: ${CONFIG_FILE}" >&2 @@ -37,37 +51,52 @@ if [[ ! -f "${CONFIG_FILE}" ]]; then fi source "${CONFIG_FILE}" -# --- New: Source Path Translation Function --- -translate_source() { - local src="$1" - # Replace the Transmission reported prefix with the local prefix. - # Example: /downloads/Books becomes /mnt/dsnas2/Books. - echo "${src/#${TRANSMISSION_PATH_PREFIX}/${LOCAL_PATH_PREFIX}}" -} +# Validate mandatory 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 -# Runtime flags -DRY_RUN=0 -INTERACTIVE=0 -CACHE_WARMUP=0 +# Set DEBUG flag if LOG_LEVEL is DEBUG. +if [[ "${LOG_LEVEL}" == "DEBUG" ]]; then + DEBUG=1 +fi -# Color codes -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Initialize storage directories +# Parse STORAGE_DIRS into an array. STORAGE_DIRS_ARRAY=() if [[ -n "${STORAGE_DIRS}" ]]; then IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}" fi -# Logging functions -log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%F %T') - $*" | tee -a "${LOG_FILE}"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%F %T') - $*" | tee -a "${LOG_FILE}"; } -log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%F %T') - $*" | tee -a "${LOG_FILE}"; } +########################### +# Helper & Utility Functions # +########################### -# Dependency check +# translate_source: Converts the Transmission‑reported path into the local path. +translate_source() { + local src="$1" + # Replace the Transmission reported prefix with the local prefix. + 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 @@ -78,7 +107,7 @@ check_dependencies() { done } -# Disk usage monitoring +# check_disk_usage: Warn if disk usage is over 90%. declare -A CHECKED_MOUNTS=() check_disk_usage() { local dir="$1" @@ -100,13 +129,14 @@ check_disk_usage() { fi } -# Checksum database +# init_checksum_db: Initializes the checksum database. init_checksum_db() { mkdir -p "$(dirname "${CHECKSUM_DB}")" - touch "${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 | \ @@ -114,10 +144,12 @@ record_checksums() { mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}" } +# file_metadata: Returns an md5 hash for the 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 @@ -142,7 +174,7 @@ files_need_processing() { local file_count file_count=$(find "${target}" -mindepth 1 -maxdepth 1 -print | wc -l) - log_info "File count for target ${target}: ${file_count}" + 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 @@ -193,6 +225,7 @@ files_need_processing() { [[ "${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[@]}") @@ -200,14 +233,17 @@ warm_cache() { 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" @@ -229,18 +265,20 @@ get_destination() { echo "${destination}" } +# handle_archives: Extracts archives found in the source directory. 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 ${arch##*/}" case "${arch##*.}" in - rar) unrar x -o- "${arch}" "${dst}";; - zip) unzip -o "${arch}" -d "${dst}";; - 7z) 7z x "${arch}" -o"${dst}";; + rar) unrar x -o- "${arch}" "${dst}" || log_error "unrar failed for ${arch}";; + zip) unzip -o "${arch}" -d "${dst}" || log_error "unzip failed for ${arch}";; + 7z) 7z x "${arch}" -o"${dst}" || log_error "7z extraction failed for ${arch}";; esac done } +# move_files: Moves files using parallel processing if enabled. move_files() { if (( PARALLEL_PROCESSING )); then parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/* @@ -249,6 +287,7 @@ move_files() { 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}"/* @@ -257,6 +296,7 @@ copy_files() { fi } +# process_copy: Validates directories, then copies/moves files from source to destination. process_copy() { local id="$1" hash="$2" src="$3" dst="$4" if [[ ! -d "${src}" ]]; then @@ -265,10 +305,7 @@ process_copy() { fi if [[ ! -d "${dst}" ]]; then log_info "Creating destination directory: ${dst}" - mkdir -p "${dst}" || { - log_error "Failed to create directory: ${dst}" - return 1 - } + mkdir -p "${dst}" || { log_error "Failed to create directory: ${dst}"; return 1; } chmod 775 "${dst}" chown debian-transmission:debian-transmission "${dst}" fi @@ -301,6 +338,7 @@ process_copy() { fi } +# process_removal: Removes a torrent via Transmission. process_removal() { local id="$1" if (( DRY_RUN )); then @@ -312,8 +350,13 @@ process_removal() { -t "${id}" --remove-and-delete } +################# +# Main Function # +################# main() { check_dependencies + + # Validate destination directories. declare -a REQUIRED_DIRS=( "${DIR_GAMES_DST}" "${DIR_APPS_DST}" @@ -331,62 +374,74 @@ main() { 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 and translate download location. - 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 - if (( $(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}" - fi + + 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 + + # Check if this source directory has already been processed. + 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 - if (( $(bc <<< "${ratio} >= ${SEED_RATIO}") )) || (( $(bc <<< "${time} >= ${SEED_TIME}") )); then - log_info "Removing torrent ${id} (Ratio: ${ratio}, Time: ${time})" - process_removal "${id}" + 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 - done + 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}" @@ -394,18 +449,10 @@ main() { check_disk_usage "${DEFAULT_DST}" } -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) DRY_RUN=1; shift ;; - --interactive) INTERACTIVE=1; shift ;; - --cache-warmup) CACHE_WARMUP=1; shift ;; - --help) - echo "Usage: $0 [--dry-run] [--interactive] [--cache-warmup]" - exit 0 - ;; - *) echo "Invalid option: $1"; exit 1 ;; - esac -done +###################### +# Command-line Parsing # +###################### +parse_args "$@" if (( INTERACTIVE )); then read -rp "Confirm processing? (y/n) " choice