more debugging and improvements

This commit is contained in:
masterdraco 2025-02-25 01:15:53 +01:00
parent 7c65712b16
commit d190672cc4
2 changed files with 166 additions and 116 deletions

View File

@ -34,6 +34,9 @@ CHECKSUM_DB="/var/lib/torrent/checksums.db"
# System settings # System settings
LOG_FILE="/var/log/torrent_mover.log" LOG_FILE="/var/log/torrent_mover.log"
# Logging level: set to "DEBUG" to enable debug messages; otherwise "INFO"
LOG_LEVEL="INFO"
# Auto-create directories # Auto-create directories
mkdir -p "${DIR_GAMES_DST}" "${DIR_APPS_DST}" \ mkdir -p "${DIR_GAMES_DST}" "${DIR_APPS_DST}" \
"${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" \ "${DIR_MOVIES_DST}" "${DIR_BOOKS_DST}" \

View File

@ -1,35 +1,49 @@
#!/bin/bash #!/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" LOCK_FILE="/var/lock/torrent-mover.lock"
MAX_AGE=300 # 5 minutes in seconds MAX_AGE=300 # 5 minutes in seconds
# Check for existing lock # Runtime flags (default values)
if [ -f "${LOCK_FILE}" ]; then DRY_RUN=0
PID=$(cat "${LOCK_FILE}") INTERACTIVE=0
if ps -p "${PID}" >/dev/null 2>&1; then CACHE_WARMUP=0
echo "torrent-mover already running (PID: ${PID}), exiting." DEBUG=0 # Will be set to 1 if LOG_LEVEL is DEBUG or if --debug is passed
exit 1
else # Associative array to track source directories that have been processed.
LOCK_AGE=$(($(date +%s) - $(stat -c %Y "${LOCK_FILE}"))) declare -A processed_source_dirs
if [ "${LOCK_AGE}" -lt "${MAX_AGE}" ]; then
echo "Recent crash detected, waiting..." ####################
exit 1 # Logging Functions#
fi ####################
echo "Removing stale lock (PID: ${PID})" # All log output goes to stderr so that command outputs remain clean.
rm -f "${LOCK_FILE}" log_debug() {
if [[ "${DEBUG}" -eq 1 ]]; then
echo -e "[DEBUG] $(date '+%F %T') - $*" | tee -a "${LOG_FILE}" >&2
fi 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 # Configuration & Validation #
##############################
set -o errexit
set -o nounset
set -o pipefail
# Load configuration
CONFIG_FILE="/etc/torrent/mover.conf" CONFIG_FILE="/etc/torrent/mover.conf"
if [[ ! -f "${CONFIG_FILE}" ]]; then if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "FATAL: Configuration file missing: ${CONFIG_FILE}" >&2 echo "FATAL: Configuration file missing: ${CONFIG_FILE}" >&2
@ -37,37 +51,52 @@ if [[ ! -f "${CONFIG_FILE}" ]]; then
fi fi
source "${CONFIG_FILE}" source "${CONFIG_FILE}"
# --- New: Source Path Translation Function --- # Validate mandatory configuration values.
translate_source() { if [[ -z "${TRANSMISSION_PATH_PREFIX:-}" || -z "${LOCAL_PATH_PREFIX:-}" ]]; then
local src="$1" echo "FATAL: TRANSMISSION_PATH_PREFIX and LOCAL_PATH_PREFIX must be set in ${CONFIG_FILE}" >&2
# Replace the Transmission reported prefix with the local prefix. exit 1
# Example: /downloads/Books becomes /mnt/dsnas2/Books. fi
echo "${src/#${TRANSMISSION_PATH_PREFIX}/${LOCAL_PATH_PREFIX}}"
}
# Runtime flags # Set DEBUG flag if LOG_LEVEL is DEBUG.
DRY_RUN=0 if [[ "${LOG_LEVEL}" == "DEBUG" ]]; then
INTERACTIVE=0 DEBUG=1
CACHE_WARMUP=0 fi
# Color codes # Parse STORAGE_DIRS into an array.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Initialize storage directories
STORAGE_DIRS_ARRAY=() STORAGE_DIRS_ARRAY=()
if [[ -n "${STORAGE_DIRS}" ]]; then if [[ -n "${STORAGE_DIRS}" ]]; then
IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}" IFS=',' read -ra STORAGE_DIRS_ARRAY <<< "${STORAGE_DIRS}"
fi fi
# Logging functions ###########################
log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%F %T') - $*" | tee -a "${LOG_FILE}"; } # Helper & Utility Functions #
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 # translate_source: Converts the Transmissionreported 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 commandline 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() { check_dependencies() {
local deps=("transmission-remote" "unrar" "unzip" "7z" "parallel" "bc") local deps=("transmission-remote" "unrar" "unzip" "7z" "parallel" "bc")
for dep in "${deps[@]}"; do for dep in "${deps[@]}"; do
@ -78,7 +107,7 @@ check_dependencies() {
done done
} }
# Disk usage monitoring # check_disk_usage: Warn if disk usage is over 90%.
declare -A CHECKED_MOUNTS=() declare -A CHECKED_MOUNTS=()
check_disk_usage() { check_disk_usage() {
local dir="$1" local dir="$1"
@ -100,13 +129,14 @@ check_disk_usage() {
fi fi
} }
# Checksum database # init_checksum_db: Initializes the checksum database.
init_checksum_db() { init_checksum_db() {
mkdir -p "$(dirname "${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}" chmod 600 "${CHECKSUM_DB}"
} }
# record_checksums: Generates checksums for files in given directories.
record_checksums() { record_checksums() {
log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads" log_info "Generating checksums with ${PARALLEL_THREADS:-$(nproc)} threads"
find "$@" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \ find "$@" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -print0 | \
@ -114,10 +144,12 @@ record_checksums() {
mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}" mv "${CHECKSUM_DB}.tmp" "${CHECKSUM_DB}"
} }
# file_metadata: Returns an md5 hash for the file metadata.
file_metadata() { file_metadata() {
find "$1" -type f ! \( -iname "*.nfo" -o -iname "*.sfv" \) -exec md5sum {} \; | sort | awk '{print $1}' 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() { files_need_processing() {
local src="$1" local src="$1"
shift shift
@ -142,7 +174,7 @@ files_need_processing() {
local file_count local file_count
file_count=$(find "${target}" -mindepth 1 -maxdepth 1 -print | wc -l) 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 if [[ "${file_count}" -eq 0 ]]; then
log_info "Empty target directory: ${target}" log_info "Empty target directory: ${target}"
empty_target_found=1 empty_target_found=1
@ -193,6 +225,7 @@ files_need_processing() {
[[ "${match_found}" -eq 1 ]] && return 1 || return 0 [[ "${match_found}" -eq 1 ]] && return 1 || return 0
} }
# warm_cache: Pre-calculates checksums for storage directories.
warm_cache() { warm_cache() {
log_info "Starting cache warmup for Movies..." log_info "Starting cache warmup for Movies..."
local targets=("${DIR_MOVIES_DST}" "${STORAGE_DIRS_ARRAY[@]}") local targets=("${DIR_MOVIES_DST}" "${STORAGE_DIRS_ARRAY[@]}")
@ -200,14 +233,17 @@ warm_cache() {
log_info "Cache warmup completed. Checksums stored in ${CHECKSUM_DB}" log_info "Cache warmup completed. Checksums stored in ${CHECKSUM_DB}"
} }
# is_processed: Checks if the torrent (by hash) has already been processed.
is_processed() { is_processed() {
grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null grep -q "^${1}$" "${PROCESSED_LOG}" 2>/dev/null
} }
# mark_processed: Records a processed torrent.
mark_processed() { mark_processed() {
echo "${1}" >> "${PROCESSED_LOG}" echo "${1}" >> "${PROCESSED_LOG}"
} }
# get_destination: Maps a source directory to a destination directory based on keywords.
declare -A PATH_CACHE declare -A PATH_CACHE
get_destination() { get_destination() {
local source_path="$1" local source_path="$1"
@ -229,18 +265,20 @@ get_destination() {
echo "${destination}" echo "${destination}"
} }
# handle_archives: Extracts archives found in the source directory.
handle_archives() { handle_archives() {
local src="$1" dst="$2" local src="$1" dst="$2"
find "${src}" -type f \( -iname "*.rar" -o -iname "*.zip" -o -iname "*.7z" \) | while read -r arch; do find "${src}" -type f \( -iname "*.rar" -o -iname "*.zip" -o -iname "*.7z" \) | while read -r arch; do
log_info "Extracting ${arch##*/}" log_info "Extracting ${arch##*/}"
case "${arch##*.}" in case "${arch##*.}" in
rar) unrar x -o- "${arch}" "${dst}";; rar) unrar x -o- "${arch}" "${dst}" || log_error "unrar failed for ${arch}";;
zip) unzip -o "${arch}" -d "${dst}";; zip) unzip -o "${arch}" -d "${dst}" || log_error "unzip failed for ${arch}";;
7z) 7z x "${arch}" -o"${dst}";; 7z) 7z x "${arch}" -o"${dst}" || log_error "7z extraction failed for ${arch}";;
esac esac
done done
} }
# move_files: Moves files using parallel processing if enabled.
move_files() { move_files() {
if (( PARALLEL_PROCESSING )); then if (( PARALLEL_PROCESSING )); then
parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/* parallel -j ${PARALLEL_THREADS:-$(nproc)} mv {} "${1}" ::: "${2}"/*
@ -249,6 +287,7 @@ move_files() {
fi fi
} }
# copy_files: Copies files using parallel processing if enabled.
copy_files() { copy_files() {
if (( PARALLEL_PROCESSING )); then if (( PARALLEL_PROCESSING )); then
parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} "${1}" ::: "${2}"/* parallel -j ${PARALLEL_THREADS:-$(nproc)} cp -r {} "${1}" ::: "${2}"/*
@ -257,6 +296,7 @@ copy_files() {
fi fi
} }
# process_copy: Validates directories, then copies/moves files from source to destination.
process_copy() { process_copy() {
local id="$1" hash="$2" src="$3" dst="$4" local id="$1" hash="$2" src="$3" dst="$4"
if [[ ! -d "${src}" ]]; then if [[ ! -d "${src}" ]]; then
@ -265,10 +305,7 @@ process_copy() {
fi fi
if [[ ! -d "${dst}" ]]; then if [[ ! -d "${dst}" ]]; then
log_info "Creating destination directory: ${dst}" log_info "Creating destination directory: ${dst}"
mkdir -p "${dst}" || { mkdir -p "${dst}" || { log_error "Failed to create directory: ${dst}"; return 1; }
log_error "Failed to create directory: ${dst}"
return 1
}
chmod 775 "${dst}" chmod 775 "${dst}"
chown debian-transmission:debian-transmission "${dst}" chown debian-transmission:debian-transmission "${dst}"
fi fi
@ -301,6 +338,7 @@ process_copy() {
fi fi
} }
# process_removal: Removes a torrent via Transmission.
process_removal() { process_removal() {
local id="$1" local id="$1"
if (( DRY_RUN )); then if (( DRY_RUN )); then
@ -312,8 +350,13 @@ process_removal() {
-t "${id}" --remove-and-delete -t "${id}" --remove-and-delete
} }
#################
# Main Function #
#################
main() { main() {
check_dependencies check_dependencies
# Validate destination directories.
declare -a REQUIRED_DIRS=( declare -a REQUIRED_DIRS=(
"${DIR_GAMES_DST}" "${DIR_GAMES_DST}"
"${DIR_APPS_DST}" "${DIR_APPS_DST}"
@ -331,62 +374,74 @@ main() {
exit 1 exit 1
fi fi
done done
init_checksum_db init_checksum_db
if (( CACHE_WARMUP )); then if (( CACHE_WARMUP )); then
warm_cache warm_cache
exit 0 exit 0
fi fi
log_info "Starting processing" log_info "Starting processing"
declare -A warned_dirs=() declare -A warned_dirs=()
transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \ 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 -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}" \ local info
-n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -t "${id}" -i) info=$(transmission-remote "${TRANSMISSION_IP}:${TRANSMISSION_PORT}" \
local hash -n "${TRANSMISSION_USER}:${TRANSMISSION_PASSWORD}" -t "${id}" -i)
hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}') local hash
local ratio hash=$(grep "Hash:" <<< "${info}" | awk '{print $2}')
ratio=$(grep "Ratio:" <<< "${info}" | awk '{print $2 == "None" ? 0 : $2}' | tr -cd '0-9.') local ratio
ratio=${ratio:-0} ratio=$(grep "Ratio:" <<< "${info}" | awk '{print $2 == "None" ? 0 : $2}' | tr -cd '0-9.')
local time ratio=${ratio:-0}
time=$(grep "Seeding Time:" <<< "${info}" | awk '{print $3 == "None" ? 0 : $3}' | tr -cd '0-9.') local time
time=${time:-0} time=$(grep "Seeding Time:" <<< "${info}" | awk '{print $3 == "None" ? 0 : $3}' | tr -cd '0-9.')
local percent_done time=${time:-0}
percent_done=$(grep "Percent Done:" <<< "${info}" | awk '{gsub(/%/, ""); print $3 == "None" ? 0 : $3}') local percent_done
percent_done=${percent_done:-0} percent_done=$(grep "Percent Done:" <<< "${info}" | awk '{gsub(/%/, ""); print $3 == "None" ? 0 : $3}')
# Extract and translate download location. percent_done=${percent_done:-0}
local reported_dir
reported_dir=$(grep -i "Location:" <<< "${info}" | awk -F": " '{print $2}' | xargs) # Extract Transmission reported directory and translate to local path.
local dir local reported_dir
dir=$(translate_source "${reported_dir}") reported_dir=$(grep -i "Location:" <<< "${info}" | awk -F": " '{print $2}' | xargs)
log_info "Torrent source directory reported: '${reported_dir}' translated to '${dir}'" local dir
local dst dir=$(translate_source "${reported_dir}")
dst=$(get_destination "${dir}") log_info "Torrent source directory reported: '${reported_dir}' translated to '${dir}'"
[[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0 local dst
if (( $(bc <<< "${percent_done} >= 100") )) && ! is_processed "${hash}"; then dst=$(get_destination "${dir}")
log_info "Processing completed torrent ${id} (${percent_done}% done)" [[ -z "${warned_dirs["${dir}"]+x}" ]] && warned_dirs["${dir}"]=0
if [[ "${dst}" == "${DEFAULT_DST}" ]] && (( warned_dirs["${dir}"] == 0 )); then
log_warn "Using default destination for: ${dir}" # Check if this source directory has already been processed.
warned_dirs["${dir}"]=1 if [[ -n "${processed_source_dirs["${dir}"]+x}" ]]; then
fi log_info "Directory ${dir} has already been processed; skipping copy for torrent ${id}"
local targets=("${dst}") elif (( $(bc <<< "${percent_done} >= 100") )) && ! is_processed "${hash}"; then
case "${dst}" in log_info "Processing completed torrent ${id} (${percent_done}% done)"
"${DIR_MOVIES_DST}") targets+=("${STORAGE_DIRS_ARRAY[@]}");; if [[ "${dst}" == "${DEFAULT_DST}" ]] && (( warned_dirs["${dir}"] == 0 )); then
esac log_warn "Using default destination for: ${dir}"
if ! files_need_processing "${dir}" "${targets[@]}"; then warned_dirs["${dir}"]=1
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
fi fi
if (( $(bc <<< "${ratio} >= ${SEED_RATIO}") )) || (( $(bc <<< "${time} >= ${SEED_TIME}") )); then local targets=("${dst}")
log_info "Removing torrent ${id} (Ratio: ${ratio}, Time: ${time})" case "${dst}" in
process_removal "${id}" "${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
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_GAMES_DST}"
check_disk_usage "${DIR_APPS_DST}" check_disk_usage "${DIR_APPS_DST}"
check_disk_usage "${DIR_MOVIES_DST}" check_disk_usage "${DIR_MOVIES_DST}"
@ -394,18 +449,10 @@ main() {
check_disk_usage "${DEFAULT_DST}" check_disk_usage "${DEFAULT_DST}"
} }
while [[ $# -gt 0 ]]; do ######################
case "$1" in # Command-line Parsing #
--dry-run) DRY_RUN=1; shift ;; ######################
--interactive) INTERACTIVE=1; shift ;; parse_args "$@"
--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
if (( INTERACTIVE )); then if (( INTERACTIVE )); then
read -rp "Confirm processing? (y/n) " choice read -rp "Confirm processing? (y/n) " choice