#!/bin/bash # Torrent Mover v5.1 - Numeric Handling Fix set -o errexit set -o nounset set -o pipefail # Load configuration CONFIG_FILE="/etc/torrent/mover.conf" source "${CONFIG_FILE}" # Runtime flags DRY_RUN=0 INTERACTIVE=0 CACHE_WARMUP=0 # Color codes RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # Initialize storage directories 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}"; } # Dependency check check_dependencies() { local deps=("transmission-remote" "unrar" "unzip" "7z" "parallel" "bc") for dep in "${deps[@]}"; do if ! command -v "${dep}" >/dev/null 2>&1; then log_error "Missing dependency: ${dep}" exit 1 fi done } # Disk usage monitoring 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=$(df -P "${dir}" | awk 'NR==2 {print $6}') [[ -z "${mount_point}" ]] && return if [[ -z "${CHECKED_MOUNTS["${mount_point}"]+x}" ]]; then local 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 } # Checksum database init_checksum_db() { mkdir -p "$(dirname "${CHECKSUM_DB}")" touch "${CHECKSUM_DB}" chmod 600 "${CHECKSUM_DB}" } record_checksums() { log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads" find "$@" -type f \( -iname "*.nfo" -o -iname "*.sfv" \) -prune -o -type f -print0 | \ parallel -0 -j ${PARALLEL_THREADS:-$(nproc)} md5sum | \ sort > "${CHECKSUM_DB}.tmp" mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}" } file_metadata() { find "$1" -type f \( -iname "*.nfo" -o -iname "*.sfv" \) -prune -o -type f -printf "%s %T@ %p\n" | \ sort | \ md5sum | \ awk '{print $1}' } files_need_processing() { local src="$1" shift local targets=("$@") [[ ! -d "${src}" ]] && return 1 local src_meta=$(file_metadata "${src}") for target in "${targets[@]}"; do [[ ! -d "${target}" ]] && continue local target_meta=$(file_metadata "${target}") [[ "${src_meta}" == "${target_meta}" ]] && return 1 done local src_checksums=$(find "${src}" -type f \( -iname "*.nfo" -o -iname "*.sfv" \) -prune -o -type f -exec md5sum {} \; | sort) for target in "${targets[@]}"; do [[ ! -d "${target}" ]] && continue local target_checksums=$(find "${target}" -type f \( -iname "*.nfo" -o -iname "*.sfv" \) -prune -o -type f -exec md5sum {} \; | sort) diff <(echo "${src_checksums}") <(echo "${target_checksums}") >/dev/null && return 1 done return 0 } 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() { grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null } mark_processed() { echo "${1}" >> "${PROCESSED_LOG}" } get_destination() { case "${1}" in *Games*) echo "${DIR_GAMES_DST}";; *Apps*) echo "${DIR_APPS_DST}";; *Movies*) echo "${DIR_MOVIES_DST}";; *Books*) echo "${DIR_BOOKS_DST}";; *) echo "${DEFAULT_DST}";; esac } 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}";; esac done } move_files() { if (( PARALLEL_PROCESSING )); then parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/* else mv "${2}"/* "${1}" fi } copy_files() { if (( PARALLEL_PROCESSING )); then parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} "${1}" ::: "${2}"/* else cp -r "${2}"/* "${1}" fi } process_copy() { local id="$1" hash="$2" src="$3" dst="$4" if (( DRY_RUN )); then log_info "[DRY RUN] Would process torrent ${id}:" log_info " - Copy files from ${src} to ${dst}" return fi mkdir -p "${dst}" handle_archives "${src}" "${dst}" case "${COPY_MODE}" in move) move_files "${dst}" "${src}";; copy) copy_files "${dst}" "${src}";; esac } 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() { check_dependencies 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=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -t "${id}" -i) local hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}') # Sanitize numeric values with fallbacks local ratio=$(grep "Ratio:" <<< "${info}" | awk '{print $2 == "None" ? 0 : $2}' | tr -cd '0-9.') ratio=${ratio:-0} # Handle empty values local time=$(grep "Seeding Time:" <<< "${info}" | awk '{print $3 == "None" ? 0 : $3}' | tr -cd '0-9.') time=${time:-0} # Handle empty values local percent_done=$(grep "Percent Done:" <<< "${info}" | awk '{gsub(/%/, ""); print $3 == "None" ? 0 : $3}') percent_done=${percent_done:-0} # Handle empty values local dir=$(grep "Location:" <<< "${info}" | cut -d' ' -f4-) local dst=$(get_destination "${dir}") # Initialize warning tracking [[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0 # 1. Handle completed downloads 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 # Determine check targets local targets=("${dst}") if [[ "${dst}" == "${DIR_MOVIES_DST}" ]]; then targets+=("${STORAGE_DIRS_ARRAY[@]}") fi 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 mark_processed "${hash}" fi # 2. Handle seeding criteria if (( $(bc <<< "${ratio} >= ${SEED_RATIO}") )) || (( $(bc <<< "${time} >= ${SEED_TIME}") )); then log_info "Removing torrent ${id} (Ratio: ${ratio}, Time: ${time})" process_removal "${id}" fi done # Final disk checks 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}" } # Argument handling 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 # Execution if (( INTERACTIVE )); then read -rp "Confirm processing? (y/n) " choice [[ "${choice}" =~ ^[Yy]$ ]] || exit 0 fi main