torrent/usr/local/bin/torrent-mover

510 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# Torrent Mover v8.0 - 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 Transmissions 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 Transmissionreported path into the local path.
translate_source() {
local src="$1"
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() {
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