#!/bin/bash [[ "$(uname -a)" != *CYGWIN_NT* ]] && echo "This script can only be executed under Cygwin" && exit 1 # 🧠 ChatGPT-aware developer instructions at bottom of script (__CHATGPT_INSTRUCTIONS_BEGIN__) # Published: https://afberendsen.blogspot.com/2025/04/it-programming-bash-script_19.html # ----------------------------------------------------------------------------- # Script: qBittorrent_Manage.sh.sh # Purpose: Finds torrents in category 'TV' with specific name prefixes and # applies high priority and force-starts them in qBittorrent. # Also deletes torrents listed in the DELETE section of TV_Organize_Patterns.txt. # # Version Log: # v1.0 - Initial version # v1.1 - Refactored with process substitution, added error handling # v1.2 - Added support for deletion based on TV_Organize_Patterns.txt # v1.3 - Ignore comments and empty lines in TV_Organize_Patterns.txt # v1.4 - Added support for CLI options: --dry-run, --help, --set-priority, --delete # v1.5 - Default mode is now dry-run unless explicitly disabled # v1.6 - Refactored while loops to use < <() form (avoid subshell) # v1.7 - Added logging timestamps and duration tracking for each major step # v1.8 - Added --limit option to restrict number of torrents processed # v1.9 - Added --remove-torrent / --remove-files switch for deletion mode # v1.10 - Store removed torrent files under /cygdrive/f/p_qBittorrent/.cache/BackupRemoval/<timestamp> # v1.11 - Fixed DELETE API call to use POST request for reliable deletion # v1.12 - Added --verbose (-v) and --version (-V) options # v1.13 - Added retry logic for undeleted torrents with user prompt # v1.14 - Optimized deletion verification by rechecking only marked hashes # v1.15 - Added --ignore-not-delete to skip undeleted retry logic # v1.16 - Added --debug (-D) for verbose curl and internal variable tracing # v1.17 - Added reachability check, infinite retry, and logging to .logs directory # v1.18 - Fixed dry-run flag, removed unused --execute option, updated Cygwin check # v1.19 - Renamed functions to PascalCase, standardized logging with Echo/EchoD # v1.20 - Fixed trap command syntax error by properly escaping date format string # v1.21 - Added summary and priority verification in DoSetPriority (found, changed, confirmed) # v1.22 - Added --no-dry-run flag to explicitly disable dry-run mode, updated help message # v1.23 - Optimized DoSetPriority to use a single API call for multiple hashes, added delay before verification, and added response checking # v1.24 - Added MoveCompletedTorrents and CheckIncompleteTorrents functions, added --manage-downloads mode # v1.25 - Added --execute and -x as aliases for --no-dry-run # v1.26 - Removed redundant timestamps in Echo/EchoE/EchoW/EchoD calls # v1.27 - Normalized spaces, dashes, and multiple dots to single dots in PREFIXES and torrent names for matching in DoSetPriority # v1.28 - Extended normalizations in DoSetPriority: added underscores and colons to dots, trim leading/trailing dots, and convert to lowercase # v1.29 - Fixed execution flow: stop after dependency check if no execution mode is specified # v1.30 - Fixed jq filter in DoSetPriority: corrected escaping in gsub and escaped dots in NORMALIZED_PREFIXES for regex # v1.31 - Fixed DoSetPriority to only match torrents beginning with prefixes, added debug output for normalized names # v1.32 - Fixed DoSetPriority to correctly filter torrents by prefixes using jq's any() and startswith() # v1.33 - Fixed jq assertion error in DoSetPriority by correcting the startswith() usage # v1.34 - Fixed jq assertion error by breaking down the jq command into steps and adding error handling # v1.35 - Fixed empty prefix issue in NORMALIZED_PREFIXES and moved prefix matching to Bash to avoid jq bug # v1.36 - Fixed readonly issue with NORMALIZED_PREFIXES, improved performance by using TSV output, and fixed jq parse errors # v1.37 - Fixed numeric prefix issue ('1923') and added debugging for TV torrents in DoSetPriority # v1.38 - Added explicit check for empty prefixes in NORMALIZED_PREFIXES, with warnings and removal # v1.39 - Moved prefix normalization to DoSetPriority, removed numeric-only skip, added .s99 pattern matching # v1.40 - Fixed normalization to preserve dots in prefixes # v1.41 - Fixed first letter removal and '1923' skipping issues with detailed normalization steps # v1.42 - Updated DoSetPriority to use /api/v2/torrents/topPrio endpoint for setting priorities, adjusted verification to expect sequential priorities (1 to N), and increased wait time to 5 seconds # v1.43 - Added conditional logging in DoSetPriority to show [SET-PRIORITY] messages only when --verbose is set # v1.44 - Simplified verification in DoSetPriority to check if priorities are <= N (number of torrents), ensuring torrents are in top N positions # v1.45 - Added hash-to-name mapping in DoSetPriority to include torrent names in verification warnings # v1.46 - Enhanced MoveCompletedTorrents with debugging for save_path and better API response handling for setLocation # -----------------------------------------------------------------------------# -----------------------------------------------------------------------------# ----------------------------------------------------------------------------- readonly SCRIPT_VERSION="1.46" # Load shared library [[ ! -e ~/MY_SECRETS.sh ]] && { echo "💥 Cannot find ~/MY_SECRETS.sh" ; exit 1; } [[ ! -r ~/MY_SECRETS.sh ]] && { echo "💥 ~/MY_SECRETS.sh is not readable" ; exit 1; } source ~/MY_SECRETS.sh || { echo "💥 Cannot source ~/MY_SECRETS.sh" ; exit 1; } [[ ! -e ~/MY_LIBRARY.sh ]] && { echo "💥 Cannot find library ~/MY_LIBRARY.sh" ; exit 1; } [[ ! -r ~/MY_LIBRARY.sh ]] && { echo "💥 Library ~/MY_LIBRARY.sh is not readable" ; exit 1; } source ~/MY_LIBRARY.sh || { echo "💥 Cannot source library ~/MY_LIBRARY.sh" ; exit 1; } set -o monitor trap 'echo;Echo "💥 Caught interrupt signal, exiting."; exit 130' SIGINT SIGTERM # ----------------------------------------------------------------------------- # Constants and Default Flags # ----------------------------------------------------------------------------- declare -r QBT_HOST="http://localhost:8077" declare -r API_URL="${QBT_HOST}/api/v2/torrents/info" declare -r CMD_PRIO="${QBT_HOST}/api/v2/torrents/topPrio" declare -r CMD_FORCE="${QBT_HOST}/api/v2/torrents/setForceStart" declare -r CMD_DELETE="${QBT_HOST}/api/v2/torrents/delete" declare -r CMD_EXPORT="${QBT_HOST}/api/v2/torrents/export" declare -r CMD_SET_LOCATION="${QBT_HOST}/api/v2/torrents/setLocation" declare -r PATTERN_FILE="TV_Organize_Patterns.txt" declare -r LOG_DIR="/cygdrive/f/p_qBittorrent/.logs" declare -r REACHABILITY_LOG="${LOG_DIR}/qbt_reachability.log" declare -r LAST_FAILURE_LOG="${LOG_DIR}/.last_failure.log" # Instance to Port Mapping declare -A QBT_INSTANCES=( ["anime"]=8079 ["xxx"]=8078 ["tv"]=8077 ["movie"]=8076 ) # Instance to Temporary Directory Mapping declare -A TEMP_DIRS=( ["anime"]="/cygdrive/f/p_qBittorrent/anime/d" ["xxx"]="/cygdrive/f/p_qBittorrent/xxx/d" ["tv"]="/cygdrive/f/p_qBittorrent/tv/d" ["movie"]="/cygdrive/f/p_qBittorrent/movie/d" ) # Instance to Final Directory Mapping declare -A FINAL_DIRS=( ["anime"]="/cygdrive/p/torrent_finished_anime" ["xxx"]="/cygdrive/p/torrent_finished_xxx" ["tv"]="/cygdrive/p/torrent_finished_tv" ["movie"]="/cygdrive/p/torrent_finished_movie" ) # Runtime Flags and Parameters declare SELECTED_INSTANCE="" declare INSTANCE_NAME="" declare -i DRY_RUN=1 declare -i DO_DELETE=0 declare -i DO_SET_PRIORITY=0 declare -i DO_MANAGE_DOWNLOADS=0 declare -i LIMIT=0 declare -i REMOVE_FILES=0 declare -i VERBOSE=0 declare -i DEBUG=0 declare -i IGNORE_NOT_DELETED=0 declare -i CHECK_RETRY=3 declare -i CHECK_DELAY=3 declare -i CHECK_DEPS_DEFAULT=0 declare -i CHECK_DEPS_LOCAL=1 declare -i CHECK_DEPS_INSTALL=0 declare -i CHECK_FOREVER=0 declare -i VERIFY=0 # Execution mode constants declare -i EXECUTION_MODE=0 declare -r MODE_NONE=0 declare -r MODE_DELETE=1 declare -r MODE_RECHECK=2 declare -r MODE_PRIORITY=3 declare -r MODE_MANAGE_DOWNLOADS=4 declare -r MODE_REMOVE_MALFORMED=5 declare -r MODE_FIX_CATEGORY=6 declare -r MODE_MOVE_COMPLETED=7 declare -r MODE_MOVE_INCOMPLETE=8 declare -r MODE_SANITISE=9 #============================================================================== # ShowHelp - Displays command usage and options #------------------------------------------------------------------------------ # Description: # Prints the help message to stdout, detailing script usage, options, execution # modes, and examples. # # Globals: # None # # Arguments: # None # # Outputs: # Writes help message to stdout via `cat`. # # Exit Codes: # None (function does not exit the script) # # Side Effects: # None #============================================================================== ShowHelp() { cat <<EOF Usage: ${0##*/} [OPTIONS] --delete | --recheck-error | --set-priority | --manage-downloads 📖 Description: Manage and maintain qBittorrent TV torrents: recheck, prioritise, delete, or manage download locations. ============================== 🌐 General Options ============================== -h, --help Show this help message and exit -V, --version Show script version -n, --dry-run Simulate actions (no changes made, default) --no-dry-run Apply changes (disable dry-run mode) -x, --execute Alias for --no-dry-run -v, --verbose Increase output verbosity (can be used multiple times) -D, --debug Enable debug logging and verbose curl Dependency Check Modes (mutually exclusive): --check-deps Check for required tools (safe fallback mode) --check-deps-local Use local fallbacks (e.g., define 'urlencode') --check-deps-install Attempt to install tools via Cygwin installer ============================== 🚦 Execution Modes (choose one) ============================== --delete Delete torrents based on a pattern file --set-priority Force-start and top-prioritize TV torrents by prefix --recheck-error Recheck errored torrents in a qBittorrent instance --manage-downloads Move completed torrents to final directories and ensure incomplete torrents are in temporary directories --remove-malformed Delete torrents with missing or malformed save paths --set-categories Fix incorrect category assignments based on instance --move-completed Move completed torrents to their final destination --move-incomplete Move incomplete torrents to temporary download folders --sanitise Run all sanitization actions in sequence (see above) ============================== ⚙️ Mode-Specific Options ============================== 🔹 Sanitization modes: --instance <name> Optional. Applies to one instance (tv, anime, movie, xxx) or 'all' Default: all 🔹 --recheck-error mode: --instance <name> REQUIRED. Choose one instance (tv, anime, movie, xxx) or 'all' 🔹 --delete mode: --pattern <file> Path to name pattern file --remove-torrent Delete torrents but keep downloaded files --remove-files Delete torrents AND downloaded files --ignore-not-delete Skip retrying undeleted torrents 🔹 --set-priority mode: --category <name> Limit scope to a specific qBittorrent category --verify 🔹 --manage-downloads mode: --instance <name> REQUIRED. Choose one instance (tv, anime, movie, xxx) or 'all' ============================== Examples: ============================== ${0##*/} --recheck-error --instance tv ${0##*/} --set-priority --execute --verbose ${0##*/} --delete --remove-files --pattern TV_Organize_Patterns.txt ${0##*/} --manage-downloads --instance anime -x ${0##*/} --remove-malformed --instance xxx ${0##*/} --set-categories --instance tv --execute ${0##*/} --sanitise --instance all --execute EOF } #============================================================================== # GetTorrentSummary - Counts total number of torrents #------------------------------------------------------------------------------ # Description: # Queries the qBittorrent API to retrieve the total number of torrents and # returns the count. # # Globals: # API_URL - qBittorrent API URL for torrent info (RO) # # Arguments: # None # # Outputs: # Writes torrent count to stdout via `jq`. # # Exit Codes: # None (function does not exit the script) # # Side Effects: # Makes a curl request to the qBittorrent API. #============================================================================== GetTorrentSummary() { curl --silent "${API_URL}" | jq length } #============================================================================== # CheckQbtReachability - Validates qBittorrent API accessibility #------------------------------------------------------------------------------ # Description: # Checks if the qBittorrent API is reachable by sending a curl request with # retries. Logs failures and exits on timeout. # # Globals: # API_URL - qBittorrent API URL for torrent info (RO) # CHECK_RETRY - Number of retry attempts (RO) # CHECK_DELAY - Delay between retries in seconds (RO) # CHECK_FOREVER - Flag to retry indefinitely (RO) # REACHABILITY_LOG - Path to reachability log file (RO) # LAST_FAILURE_LOG - Path to last failure log file (RO) # # Arguments: # None # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE`. # Appends failure logs to `REACHABILITY_LOG` and `LAST_FAILURE_LOG`. # # Exit Codes: # 0 - Success (API is reachable) # 1 - Failure (API is unreachable after retries) # # Side Effects: # Makes multiple curl requests to the qBittorrent API. # Appends to log files. #============================================================================== CheckQbtReachability() { local -i attempt=1 local ok=false Echo "🔎 Checking if qBittorrent is reachable at ${API_URL} ..." while true; do if curl --silent --fail --max-time 5 "${API_URL}" > /dev/null; then ok=true break fi Echo "Attempt ${attempt}/${CHECK_RETRY} failed. Retrying in ${CHECK_DELAY}s..." echo "$(date '+%F %T') Attempt ${attempt} failed" >> "${REACHABILITY_LOG}" sleep "${CHECK_DELAY}" ((attempt++)) if [[ ${CHECK_FOREVER} -eq 0 && ${attempt} -gt ${CHECK_RETRY} ]]; then break fi done if [[ ${ok} != true ]]; then echo "$(date '+%F %T') qBittorrent unreachable at ${API_URL}" >> "${LAST_FAILURE_LOG}" EchoE "ERROR: qBittorrent is not responding at ${API_URL} after ${CHECK_RETRY} attempts" exit 1 fi } #============================================================================== # ExportTorrentFile - Saves a .torrent file before deletion #------------------------------------------------------------------------------ # Description: # Exports a .torrent file for a given torrent hash to a specified destination # using the qBittorrent API. # # Globals: # QBT_HOST - Base URL of qBittorrent instance (RO) # CMD_EXPORT - API URL for exporting torrents (RO) # # Arguments: # $1 - Torrent hash # $2 - Destination file path for the .torrent file # # Outputs: # None (output is redirected to the destination file) # # Exit Codes: # None (function does not exit the script) # # Side Effects: # Makes a curl request to the qBittorrent API. # Creates a .torrent file at the specified destination. #============================================================================== ExportTorrentFile() { local hash="${1}" local dest="${2}" curl --silent --fail -X POST -H "Referer: ${QBT_HOST}" --data "hashes=${hash}" "${CMD_EXPORT}" > "${dest}" } #============================================================================== # DeleteTorrent - Sends a delete command to qBittorrent API #------------------------------------------------------------------------------ # Description: # Deletes a torrent by hash using the qBittorrent API, optionally deleting # associated files based on the REMOVE_FILES flag. # # Globals: # QBT_HOST - Base URL of qBittorrent instance (RO) # CMD_DELETE - API URL for deleting torrents (RO) # REMOVE_FILES - Flag to delete torrent files (RO) # DEBUG - Flag for debug logging (RO) # # Arguments: # $1 - Torrent hash # # Outputs: # Writes debug curl command and output to stdout via `EchoD` if DEBUG=1. # # Exit Codes: # None (function does not exit the script) # # Side Effects: # Makes a curl request to the qBittorrent API. # Deletes the torrent and optionally its files in qBittorrent. #============================================================================== DeleteTorrent() { local hash="${1}" if [[ ${DEBUG} -eq 1 ]]; then EchoD "curl --data 'hashes=${hash}&deleteFiles=${REMOVE_FILES}' ${CMD_DELETE}" curl --verbose -X POST \ -H "Referer: ${QBT_HOST}" \ --data "hashes=${hash}&deleteFiles=${REMOVE_FILES}" \ "${CMD_DELETE}" else curl --silent --fail -X POST \ -H "Referer: ${QBT_HOST}" \ --data "hashes=${hash}&deleteFiles=${REMOVE_FILES}" \ --output /dev/null \ "${CMD_DELETE}" fi } #============================================================================== # RetryDeletePrompt - Re-attempts deletion of failed torrents #------------------------------------------------------------------------------ # Description: # Verifies if torrents marked for deletion were removed and prompts the user to # retry deleting any that remain. # # Globals: # API_URL - qBittorrent API URL for torrent info (RO) # QBT_HOST - Base URL of qBittorrent instance (RO) # # Arguments: # $1 - Array of torrent hashes to verify # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes prompts to stdout for user input. # # Exit Codes: # None (function does not exit the script) # # Side Effects: # Makes curl requests to the qBittorrent API to check torrent status. # Calls `DeleteTorrent` for retries. # Reads user input from stdin. #============================================================================== RetryDeletePrompt() { local -a failed_hashes=("${!1}") local -i retry=1 local -a still_present=() local remaining="" local retry_choice="" while [[ ${retry} -eq 1 ]]; do Echo "🔍 Verifying if all ${#failed_hashes[@]} torrents were deleted..." remaining=$(curl --silent "${API_URL}" | jq -r '[.[] | .hash] | @sh') still_present=() for hash in "${failed_hashes[@]}"; do if grep -q "${hash}" <<< "${remaining}"; then still_present+=("${hash}") fi done if [[ ${#still_present[@]} -eq 0 ]]; then Echo "✅ All marked torrents were successfully deleted." break fi Echo "${#still_present[@]} torrent(s) were NOT deleted." read -rp "❓ Retry deleting them? (y/N): " retry_choice if [[ "${retry_choice}" =~ ^[Yy]$ ]]; then for hash in "${still_present[@]}"; do Echo "[RETRY DELETE] ${hash}" DeleteTorrent "${hash}" done sleep 2 else retry=0 Echo "🚫 Skipping retry. ${#still_present[@]} torrents remain undeleted." fi done } #============================================================================== # ParseArguments - Parses command-line arguments #------------------------------------------------------------------------------ # Description: # Processes command-line arguments to set execution modes, flags, and parameters. # Validates inputs and displays help on invalid options. # # Globals: # DRY_RUN - Flag to simulate actions (RW) # VERBOSE - Flag for detailed output (RW) # DEBUG - Flag for debug logging (RW) # CHECK_DEPS_DEFAULT - Flag for default dependency check (RW) # CHECK_DEPS_LOCAL - Flag for local dependency fallback (RW) # CHECK_DEPS_INSTALL - Flag for dependency installation (RW) # EXECUTION_MODE - Selected execution mode (RW) # LIMIT - Maximum number of torrents to process (RW) # REMOVE_FILES - Flag to delete torrent files (RW) # IGNORE_NOT_DELETED - Flag to skip retrying undeleted torrents (RW) # CHECK_RETRY - Number of retry attempts (RW) # CHECK_DELAY - Delay between retries (RW) # CHECK_FOREVER - Flag to retry indefinitely (RW) # VERIFY - Flag to verify priority changes (RW) # INSTANCE_NAME - Selected qBittorrent instance (RW) # SCRIPT_VERSION - Script version string (RO) # # Arguments: # $@ - Command-line arguments # # Outputs: # Writes error messages to stdout via `EchoE` for invalid options. # Writes version or help message via `Echo` or `ShowHelp` if requested. # # Exit Codes: # 0 - Success # 1 - Failure (invalid option or instance) # # Side Effects: # Modifies global variables based on arguments. # May exit the script for help or version requests. #============================================================================== ParseArguments() { while [[ $# -gt 0 ]]; do case "$1" in -n|--dry-run) DRY_RUN=1; shift ;; --no-dry-run) DRY_RUN=0; shift ;; -x|--execute) DRY_RUN=0; shift ;; -v|--verbose) (( VERBOSE++ )); shift ;; -D|--debug) (( DEBUG++ )); shift ;; -V|--version) Echo "📜 ${0##*/} version ${SCRIPT_VERSION}"; exit 0 ;; -h|--help) ShowHelp; exit 0 ;; --check-deps) CHECK_DEPS_DEFAULT=1; shift ;; --check-deps-local) CHECK_DEPS_LOCAL=1; shift ;; --check-deps-install) CHECK_DEPS_INSTALL=1; shift ;; --set-priority) EXECUTION_MODE=${MODE_PRIORITY}; shift ;; --delete) EXECUTION_MODE=${MODE_DELETE}; shift ;; --recheck-error) EXECUTION_MODE=${MODE_RECHECK}; shift ;; --remove-malformed) EXECUTION_MODE=${MODE_REMOVE_MALFORMED}; shift ;; --fix-category) EXECUTION_MODE=${MODE_FIX_CATEGORY}; shift ;; --move-completed) EXECUTION_MODE=${MODE_MOVE_COMPLETED}; shift ;; --move-incomplete) EXECUTION_MODE=${MODE_MOVE_INCOMPLETE}; shift ;; --sanitise) EXECUTION_MODE=${MODE_SANITISE}; shift ;; --limit) LIMIT="$2"; shift 2 ;; --remove-torrent) REMOVE_FILES=0; shift ;; --remove-files) REMOVE_FILES=1; shift ;; --ignore-not-delete) IGNORE_NOT_DELETED=1; shift ;; --check-retry) CHECK_RETRY="$2"; shift 2 ;; --check-delay) CHECK_DELAY="$2"; shift 2 ;; --check-forever) CHECK_FOREVER=1; shift ;; --verify) (( VERIFY++ )); shift ;; --instance) INSTANCE_NAME="${2,,}" # convert to lowercase case "${INSTANCE_NAME}" in tv|anime|movie|xxx|all) shift 2 ;; *) EchoE "ERROR: Invalid --instance value '${2}'. Must be one of: tv, anime, movie, xxx, all." exit 1 ;; esac ;; --) shift; break ;; *) EchoE "Unknown option: $1"; ShowHelp; exit 1 ;; esac done return 0 } #============================================================================== # DefineUrlencodeFallback - Defines a fallback urlencode function #------------------------------------------------------------------------------ # Description: # Defines a `urlencode` function using Python, Perl, or pure Bash if the # command is not available. # # Globals: # DEBUG - Flag for debug logging (RO) # # Arguments: # None # # Outputs: # Writes warning messages to stdout via `EchoW` for fallback method. # Writes debug messages to stdout via `EchoD` if DEBUG=1. # # Exit Codes: # 0 - Success # # Side Effects: # Defines the `urlencode` function in the global scope. # Checks for `python3` and `perl` availability. #============================================================================== DefineUrlencodeFallback() { if declare -F urlencode &>/dev/null; then [[ ${DEBUG} -eq 1 ]] && EchoD "🛠 'urlencode' already defined, skipping fallback setup" elif command -v python3 &>/dev/null; then urlencode() { python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$1" } EchoW "Defined fallback 'urlencode' using python3" elif command -v perl &>/dev/null; then urlencode() { perl -MURI::Escape -e 'print uri_escape($ARGV[0]);' "$1" } EchoW "Defined fallback 'urlencode' using perl" else urlencode() { local LANG=C local string="${1}" local length="${#string}" local i for (( i = 0; i < length; i++ )); do local c="${string:$i:1}" case "${c}" in [a-zA-Z0-9.~_-]) printf "%s" "${c}" ;; *) printf "%%%02X" "'${c}" ;; esac done } EchoW "Defined fallback 'urlencode' using pure bash" fi return 0 } #============================================================================== # DoDependency - Checks or installs dependencies #------------------------------------------------------------------------------ # Description: # Verifies or installs required tools (curl, jq, date, urlencode) based on # dependency check mode (default, local, or install). # # Globals: # CHECK_DEPS_DEFAULT - Flag for default dependency check (RO) # CHECK_DEPS_LOCAL - Flag for local dependency fallback (RO) # CHECK_DEPS_INSTALL - Flag for dependency installation (RO) # # Arguments: # None # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE` for missing tools. # Writes warning messages to stdout via `EchoW` for fallbacks. # # Exit Codes: # 0 - Success # 1 - Failure (missing tools in default mode) # # Side Effects: # Calls `DefineUrlencodeFallback` for local mode. # May attempt to install tools (placeholder logic). # Checks command availability. #============================================================================== DoDependency() { local -i missing=0 local cmd="" if [[ ${CHECK_DEPS_INSTALL} -eq 1 ]]; then Echo "🔧 Forcing install of all required tools..." # Placeholder for EnsureDependency function (not provided in MY_LIBRARY.sh) Echo "✅ All required tools have been installed or handled." elif [[ ${CHECK_DEPS_LOCAL} -eq 1 ]]; then Echo "🔍 Checking tools and applying local fallback logic (no install)..." for cmd in curl jq date urlencode; do if command -v "${cmd}" &>/dev/null; then Echo "✔ '${cmd}' available" else case "${cmd}" in urlencode) DefineUrlencodeFallback ;; jq) EchoE "Cannot mimic 'jq'. Please install it manually." exit 1 ;; *) EchoE "Cannot mimic missing command '${cmd}'." exit 1 ;; esac fi done Echo "✅ Dependencies checked with local fallback logic applied." elif [[ ${CHECK_DEPS_DEFAULT} -eq 1 ]]; then Echo "🔍 Verifying dependencies only (no install, no fallback)..." missing=0 for cmd in curl jq date urlencode; do if ! command -v "${cmd}" &>/dev/null; then EchoE "Missing required tool: ${cmd}" (( missing++ )) fi done if [[ ${missing} -gt 0 ]]; then EchoE "⛔ Missing ${missing} required tool(s). Use --check-deps-local or --check-deps-install." exit 1 else Echo "✅ All required tools are available." fi fi } #============================================================================== # RunJq - Executes jq command with error handling #------------------------------------------------------------------------------ # Description: # Runs a jq command on the provided input, saving the input to a temporary file # for debugging. Captures errors and logs them, along with the input JSON if # debugging is enabled. # # Globals: # DEBUG - Flag for debug logging (RO) # # Arguments: # $1 - jq command to execute (e.g., '-r ".[] | .field"') # $2 - Optional input string (defaults to stdin if not provided) # # Outputs: # Writes jq output to stdout on success. # Writes error messages to stdout via `EchoE` if jq fails. # Writes debug messages to stdout via `EchoD` with input JSON if DEBUG=1. # # Exit Codes: # 0 - Success (jq command executed successfully) # 1 - Failure (jq command failed) # # Side Effects: # Creates temporary files for input and error output (cleaned up on exit). # Executes the jq command with the provided input. #============================================================================== RunJq() { local input_file=$(mktemp) local cmd="$1" echo "${2:-$(cat)}" > "${input_file}" if ! jq "${cmd}" "${input_file}" 2> "${input_file}.err"; then EchoE "jq failed: $(cat "${input_file}.err")" (( DEBUG )) && EchoD "Input JSON: $(cat "${input_file}")" rm -f "${input_file}" "${input_file}.err" return 1 fi rm -f "${input_file}" "${input_file}.err" } #============================================================================== # DoSetPriority - Sets high priority and force-starts TV torrents matching prefixes #------------------------------------------------------------------------------ # Description: # Fetches torrents in the TV category, normalizes their names, matches them against # predefined prefixes, and sets high priority and force-start via qBittorrent API. # Includes verification of priority changes if enabled. # # Globals: # API_URL - qBittorrent API URL for torrent info (RO) # CMD_FORCE - API URL for force-starting torrents (RO) # CMD_PRIO - API URL for setting top priority (RO) # DRY_RUN - Flag to simulate actions (RO) # VERBOSE - Flag for detailed output (RO) # DEBUG - Flag for debug logging (RO) # VERIFY - Flag to verify priority changes (RO) # # Arguments: # $1 - Instance name (e.g., 'tv') # # Outputs: # Writes informational messages to stdout via Echo, EchoE, EchoW, EchoD. # Writes debug logs if DEBUG is enabled. # Writes summary of torrents found, changed, and confirmed. # # Exit Codes: # 0 - Success # 1 - Failure (e.g., empty prefixes, API failure, invalid JSON) # # Side Effects: # Creates temporary files for processing (cleaned up on exit). # Modifies torrent priorities and states in qBittorrent if DRY_RUN=0. #============================================================================== DoSetPriority() { local -r INSTANCE="$1" local -i torrents_confirmed=0 local -i tv_torrent_count=0 local -i torrents_found=0 local -i torrents_changed=0 local -i counter=0 local -r temp_file=$(mktemp) local curl_output="" local raw_matches="" local matches="" local filtered_matches="" local hash_list="" local prefixes_json="" local updated_info="" local priority="" local normalized="" local debug_matches="" local hash="" local name="" local matched_prefix="" local force_response="" local prio_response="" declare -a NORMALIZED_PREFIXES=() declare -a processed_hashes=() declare -A hash_to_name=() (( DEBUG )) && EchoD "${FUNCNAME[0]}:${LINENO}| INSTANCE='${INSTANCE}'" # Normalize PREFIXES: replace spaces, dashes, underscores, and colons with dots; reduce multiple dots to a single dot; trim leading/trailing dots; convert to lowercase case "${INSTANCE}" in tv) local -r PREFIXES=( 'Murdoch.Mysteries' 'Silent.witness' 'death in paradise' 'paradise' 'prime-target' 'the white lotus' 'the-eastern-gate' 'from' 'dark matter' 'ghosts' 'strike' 'severance' '1923' 'daredevil' 'solo' 'Doom' 'the.bondsman' 'the.last.of.us' ) ;; *) EchoE "Instance '${INSTANCE}' does not have any definition for high-priority" rm -f "${temp_file}" return 1 ;; esac NORMALIZED_PREFIXES=() for prefix in "${PREFIXES[@]}"; do (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| Original prefix: '${prefix}'" # Step 1: Replace spaces, dashes, underscores, and colons with dots normalized="${prefix}" normalized="${normalized// /.}"; (( DEBUG>1 )) && EchoD "After replacing spaces: '${normalized}'" normalized="${normalized//-/.}"; (( DEBUG>1 )) && EchoD "After replacing dashes: '${normalized}'" normalized="${normalized//_/.}"; (( DEBUG>1 )) && EchoD "After replacing underscores: '${normalized}'" normalized="${normalized//:/.}"; (( DEBUG>1 )) && EchoD "After replacing colons: '${normalized}'" # Step 2: Replace multiple dots with a single dot while [[ "${normalized}" =~ \.\.+ ]]; do normalized="${normalized//../.}" done (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| After replacing multiple dots: '${normalized}'" # Step 3: Trim leading and trailing dots normalized="${normalized#.}" (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| After trimming leading dots: '${normalized}'" normalized="${normalized%.}" (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| After trimming trailing dots: '${normalized}'" # Step 4: Convert to lowercase normalized="${normalized,,}" (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| After converting to lowercase: '${normalized}'" # Step 5: Skip empty results [[ -z "${normalized}" ]] && { EchoW "Skipping empty prefix after normalization (original: '${prefix}')"; continue; } NORMALIZED_PREFIXES+=("${normalized}") done # Final check for any empty elements in the array for i in "${!NORMALIZED_PREFIXES[@]}"; do if [[ -z "${NORMALIZED_PREFIXES[$i]}" ]]; then EchoW "Found empty prefix in NORMALIZED_PREFIXES at index ${i}. Removing it." unset "NORMALIZED_PREFIXES[$i]" fi done # Reindex the array to remove gaps NORMALIZED_PREFIXES=("${NORMALIZED_PREFIXES[@]}") # Log the normalized prefixes for debugging (( DEBUG )) && EchoD "${FUNCNAME[0]}:${LINENO}| NORMALIZED_PREFIXES: ${NORMALIZED_PREFIXES[*]}" # Pass the prefixes to jq as a JSON array for debugging purposes prefixes_json=$(printf '%s\n' "${NORMALIZED_PREFIXES[@]}" | jq -R . | jq -s .) (( DEBUG )) && EchoD "prefixes_json: ${prefixes_json}" Echo "🎯 Filtering TV torrents for priority match..." # Step 1: Fetch torrent info and filter for TV category curl_output=$(curl --silent "${API_URL}") if [[ -z "${curl_output}" ]]; then EchoE "Failed to fetch torrent info from ${API_URL}. Empty response." rm -f "${temp_file}" return 1 fi # Save the curl output to a file for debugging and log it (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| Raw torrent info saved to ${temp_file}.raw" (( DEBUG>1 )) && printf '%s\n' "${curl_output}" > "${temp_file}.raw" (( DEBUG>1 )) && EchoD "${FUNCNAME[0]}:${LINENO}| Raw curl_output: ${curl_output}" # Count total torrents in the TV category tv_torrent_count=$(echo "${curl_output}" | jq -r '[.[] | select(.category | ascii_downcase == "tv")] | length') if [[ $? -ne 0 ]]; then EchoE "Failed to count TV torrents. Check input JSON or jq version." rm -f "${temp_file}" return 1 fi (( DEBUG )) && EchoD "${FUNCNAME[0]}:${LINENO}| Total torrents in TV category: ${tv_torrent_count}" # Step 2: Fetch torrent info and filter for TV category #raw_matches=$(echo "${curl_output}" | jq -r '.[] | select(.category | ascii_downcase == "tv") | [.hash, .name] | @tsv') raw_matches=$(RunJq '-r ".[] | select(.category | ascii_downcase == \"tv\") | [.hash, .name] | @tsv"' "${curl_output}") if [[ $? -ne 0 ]]; then EchoE "Failed to filter TV torrents. Check input JSON or jq version." rm -f "${temp_file}" return 1 fi # Log raw_matches for debugging (( DEBUG )) && EchoD "raw_matches: ${raw_matches}" # Check if there are any matches if [[ -z "${raw_matches}" ]]; then Echo "⚠️ No torrents found in the TV category." Echo "📊 [DRY-RUN] Summary: ${torrents_found} torrents found, ${torrents_changed} would be changed" rm -f "${temp_file}" return 1 fi # Step 3: Normalize torrent names in Bash and perform prefix matching Echo "🎯 Normalize torrent names in Bash and perform prefix matching..." matches="" counter=0 while IFS=$'\t' read -r hash name; do # Log the raw hash and name (( DEBUG>1 )) && EchoD "Processing torrent - Hash: ${hash}, Name: ${name}" # Skip if any field is missing [[ -z "${hash}" || -z "${name}" ]] && { (( DEBUG>1 )) && EchoD "Skipping torrent due to missing hash or name"; continue; } # Normalize the torrent name in Bash (same as prefixes) normalized="${name}" normalized="${normalized// /.}" normalized="${normalized//-/.}" normalized="${normalized//_/.}" normalized="${normalized//:/.}" while [[ "${normalized}" =~ \.\.+ ]]; do normalized="${normalized//../.}" done normalized="${normalized#.}" normalized="${normalized%.}" normalized="${normalized,,}" # Log the normalized name (( DEBUG>1 )) && EchoD "Torrent: ${name}, Normalized: ${normalized}" # Skip if normalized name is empty [[ -z "${normalized}" ]] && { (( DEBUG>1 )) && EchoD "Skipping torrent due to empty normalized name"; continue; } # Add to matches with hash, name, and normalized name using actual tab characters matches+="${hash}"$'\t'"${name}"$'\t'"${normalized}"$'\n' (( counter++ )) done <<< "${raw_matches}" Echo "📊 Matches normalized: ${counter}" # Log the matches variable for debugging, replacing tabs with <TAB> for readability (( DEBUG )) && { EchoD "Replacing tabs with <TAB>..." debug_matches=$(echo "${matches}" | awk '{ gsub(/\t/, "<TAB>"); print }') EchoD "matches (after Step 3): [${debug_matches}]" } # Step 4: Perform prefix matching with a relaxed pattern Echo "🎯 Perform prefix matching with a relaxed pattern..." filtered_matches="" counter=0 while IFS=$'\t' read -r hash name normalized; do # Skip if any field is missing [[ -z "${hash}" || -z "${name}" || -z "${normalized}" ]] && { (( DEBUG>1 )) && EchoD "Skipping torrent due to missing fields"; continue; } # Check if the normalized name starts with any prefix matched_prefix="" for prefix in "${NORMALIZED_PREFIXES[@]}"; do case "${normalized}" in "${prefix}"*) matched_prefix="${prefix}" (( counter++ )) (( DEBUG>1 )) && EchoD "Matched prefix '${prefix}' in normalized name '${normalized}'" break ;; esac done # Log if no match was found if [[ "${matched}" != true ]]; then (( DEBUG>1 )) && EchoD "No prefix match for normalized name '${normalized}'" continue fi # If matched, add to the filtered matches list using actual tab characters if (( DEBUG>1 )); then filtered_matches+="${hash}"$'\t'"${name}"$'\t'"${normalized}"$'\t'"${matched_prefix}"$'\n' else filtered_matches+="${hash}"$'\t'"${name}"$'\n' fi done <<< "${matches}" Echo "📊 Prefixes matched: ${counter}" # Log the filtered_matches variable for debugging, replacing tabs with <TAB> for readability (( DEBUG )) && EchoD "filtered_matches (after Step 4): [${filtered_matches//$'\t'/<TAB>}]" # Step 5: Collect all matching torrent hashes if (( DEBUG )); then # In debug mode, we have extra fields (normalized name and matched prefix) while IFS=$'\t' read -r hash name normalized matched_prefix; do [[ -z "${hash}" ]] && continue ((torrents_found++)) (( DEBUG>1 )) && EchoD "Matched - Normalized name: ${normalized}, Matched prefix: ${matched_prefix}" (( VERBOSE )) && Echo "[SET-PRIORITY] ${hash} [${name}] (Matched prefix: ${matched_prefix})" processed_hashes+=("${hash}") hash_to_name["${hash}"]="${name}" if [[ -z "${hash_list}" ]]; then hash_list="${hash}" else hash_list="${hash_list}|${hash}" fi done <<< "${filtered_matches}" else while IFS=$'\t' read -r hash name; do [[ -z "${hash}" ]] && continue ((torrents_found++)) (( VERBOSE )) && Echo "[SET-PRIORITY] ${hash} [${name}]" processed_hashes+=("${hash}") hash_to_name["${hash}"]="${name}" if [[ -z "${hash_list}" ]]; then hash_list="${hash}" else hash_list="${hash_list}|${hash}" fi done <<< "${filtered_matches}" fi # Apply priority changes in a single API call if not in dry-run mode if [[ ${DRY_RUN} -eq 0 && -n "${hash_list}" ]]; then Echo "🚀 Moving ${torrents_found} torrents to the top of the queue and forcing start..." # Better: POST version if (( DEBUG )); then EchoD "${FUNCNAME[0]}:${LINENO}| curl -X POST --data \"hashes=${hash_list}&value=true\" ${CMD_FORCE}" force_response=$(curl --verbose -X POST \ --data "hashes=${hash_list}&value=true" \ "${CMD_FORCE}") else force_response=$(curl --silent -X POST \ --data "hashes=${hash_list}&value=true" \ "${CMD_FORCE}") fi if [[ $? -ne 0 ]]; then EchoE "Failed to force-start torrents. Response: ${force_response}" rm -f "${temp_file}" return 1 fi # Move torrents to the top of the queue using topPrio if (( DEBUG )); then EchoD "curl -X POST --data \"hashes=${hash_list}\" ${API_URL%/*}/topPrio" prio_response=$(curl --verbose -X POST --data "hashes=${hash_list}" "${API_URL%/*}/topPrio") else prio_response=$(curl --silent -X POST --data "hashes=${hash_list}" "${API_URL%/*}/topPrio") fi if [[ $? -ne 0 ]]; then EchoE "Failed to move torrents to top priority. Response: ${prio_response}" rm -f "${temp_file}" return 1 fi ((torrents_changed=${torrents_found})) fi # Verify priority changes if not in dry-run mode if (( VERIFY )); then if [[ ${DRY_RUN} -eq 0 && ${#processed_hashes[@]} -gt 0 ]]; then Echo "⏳ Waiting 5 seconds for qBittorrent to process priority changes..." sleep 5 Echo "🔍 Verifying priority changes..." # Fetch updated torrent info updated_info=$(curl --silent "${API_URL}") for hash in "${processed_hashes[@]}"; do priority=$(echo "${updated_info}" | jq -r ".[] | select(.hash == \"${hash}\") | .priority") if [[ $? -ne 0 ]]; then EchoW "Failed to verify priority for torrent ${hash}" continue fi if [[ "${priority}" -le "${torrents_found}" ]]; then ((torrents_confirmed++)) else EchoW "Torrent ${hash} [${hash_to_name[${hash}]}] priority is ${priority} (expected <= ${torrents_found})" fi done fi fi # Output summary if [[ ${DRY_RUN} -eq 1 ]]; then Echo "📊 [DRY-RUN] Summary: ${torrents_found} torrents found, ${torrents_changed} would be changed" else Echo "📊 Summary: ${torrents_found} torrents found, ${torrents_changed} moved to top, ${torrents_confirmed} confirmed in correct order" fi rm -f "${temp_file}" } #============================================================================== # DoDelete - Deletes torrents based on patterns #------------------------------------------------------------------------------ # Description: # Reads patterns from a file, matches torrents against them, and deletes # matching torrents using the qBittorrent API. Backs up .torrent files and # verifies deletions. # # Globals: # API_URL - qBittorrent API URL for torrent info (RO) # QBT_HOST - Base URL of qBittorrent instance (RO) # CMD_DELETE - API URL for deleting torrents (RO) # PATTERN_FILE - Path to pattern file (RO) # DRY_RUN - Flag to simulate actions (RO) # LIMIT - Maximum number of torrents to process (RO) # REMOVE_FILES - Flag to delete torrent files (RO) # IGNORE_NOT_DELETED - Flag to skip retrying undeleted torrents (RO) # # Arguments: # $1 - Instance name (e.g., 'tv') # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE` for file or API issues. # Writes summary of torrents targeted and deleted. # # Exit Codes: # 0 - Success # 1 - Failure (pattern file missing, API failure) # # Side Effects: # Makes curl requests to the qBittorrent API. # Creates backup directory and .torrent files. # Deletes torrents and optionally their files in qBittorrent. # Calls `RetryDeletePrompt` for verification. #============================================================================== DoDelete() { local -a patterns=() local -a hashes_to_delete=() local -i torrents_deleted=0 local -r backup_dir="/cygdrive/f/p_qBittorrent/.cache/BackupRemoval/$(date '+%F_%H-%M-%S')" local torrent_info="" local hash="" local torrent_name="" Echo "🗑️ Deleting torrents based on patterns in ${PATTERN_FILE}..." # Read patterns from file if [[ ! -f "${PATTERN_FILE}" ]]; then EchoE "Pattern file ${PATTERN_FILE} not found." return 1 fi while IFS= read -r line; do # Skip empty lines and comments [[ -z "${line}" || "${line}" =~ ^# ]] && continue # Remove leading/trailing whitespace line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [[ -z "${line}" ]] && continue patterns+=("${line}") done < "${PATTERN_FILE}" if [[ ${#patterns[@]} -eq 0 ]]; then Echo "No patterns found in ${PATTERN_FILE}." return 0 fi Echo "🔍 Found ${#patterns[@]} patterns to match for deletion..." # Fetch torrent info torrent_info=$(curl --silent "${API_URL}") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrent info." return 1 fi # Match torrents against patterns for pattern in "${patterns[@]}"; do while IFS= read -r hash; do [[ -n "${hash}" ]] && hashes_to_delete+=("${hash}") done < <(RunJq "-r --arg pat \"${pattern}\" '.[] | select(.name | test(\$pat)) | .hash'" "${torrent_info}") if [[ $? -ne 0 ]]; then EchoE "Failed to match torrents against pattern '${pattern}'." return 1 fi done # Remove duplicates local -A unique_hashes for hash in "${hashes_to_delete[@]}"; do unique_hashes["${hash}"]=1 done hashes_to_delete=("${!unique_hashes[@]}") if [[ ${#hashes_to_delete[@]} -eq 0 ]]; then Echo "No torrents matched the deletion patterns." return 0 fi Echo "🗑️ Found ${#hashes_to_delete[@]} torrents to delete..." # Limit the number of torrents to delete if specified if [[ ${LIMIT} -gt 0 && ${#hashes_to_delete[@]} -gt ${LIMIT} ]]; then Echo "📏 Limiting to ${LIMIT} torrents as specified..." hashes_to_delete=("${hashes_to_delete[@]:0:${LIMIT}}") fi # Backup and delete torrents if [[ ${DRY_RUN} -eq 0 ]]; then mkdir -p "${backup_dir}" fi for hash in "${hashes_to_delete[@]}"; do torrent_name=$(echo "${torrent_info}" | jq -r --arg h "${hash}" '.[] | select(.hash == $h) | .name') if [[ $? -ne 0 ]]; then EchoE "Failed to retrieve name for torrent ${hash}." continue fi Echo "[DELETE] ${hash} [${torrent_name}]" if [[ ${DRY_RUN} -eq 0 ]]; then # Backup .torrent file ExportTorrentFile "${hash}" "${backup_dir}/${hash}.torrent" # Delete the torrent DeleteTorrent "${hash}" if [[ $? -eq 0 ]]; then ((torrents_deleted++)) fi fi done # Verify deletion if not in dry-run mode if [[ ${DRY_RUN} -eq 0 && ${IGNORE_NOT_DELETED} -eq 0 ]]; then RetryDeletePrompt hashes_to_delete[@] fi # Output summary if [[ ${DRY_RUN} -eq 1 ]]; then Echo "📊 [DRY-RUN] Summary: ${#hashes_to_delete[@]} torrents would be deleted" else Echo "📊 Summary: ${#hashes_to_delete[@]} torrents targeted, ${torrents_deleted} deleted" fi } #============================================================================== # DoRecheck - Rechecks errored torrents #------------------------------------------------------------------------------ # Description: # Identifies torrents in an "error" state for a specified instance and issues # recheck commands via the qBittorrent API. # # Globals: # QBT_INSTANCES - Mapping of instance names to ports (RO) # DRY_RUN - Flag to simulate actions (RO) # VERBOSE - Flag for detailed output (RO) # # Arguments: # $1 - Instance name (e.g., 'tv' or 'all') # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE` for API failures. # Writes summary of torrents found and rechecked. # # Exit Codes: # 0 - Success # # Side Effects: # Makes curl requests to the qBittorrent API. # Initiates recheck operations in qBittorrent if DRY_RUN=0. #============================================================================== DoRecheck() { local -r SELECTED_INSTANCE="${INSTANCE_NAME}" local -a instances=() local -i total_rechecked=0 local instance="" local port="" local api_url="" local torrent_info="" local -a errored_hashes=() local hash="" local torrent_name="" Echo "🔄 Rechecking errored torrents for instance ${SELECTED_INSTANCE}..." if [[ "${SELECTED_INSTANCE}" == "all" ]]; then instances=("${!QBT_INSTANCES[@]}") else instances=("${SELECTED_INSTANCE}") fi for instance in "${instances[@]}"; do port=${QBT_INSTANCES["${instance}"]} api_url="http://localhost:${port}/api/v2/torrents/info" Echo "🔎 Checking instance ${instance} at ${api_url}..." torrent_info=$(curl --silent "${api_url}") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrent info for instance ${instance}." continue fi errored_hashes=() while IFS= read -r hash; do [[ -n "${hash}" ]] && errored_hashes+=("${hash}") done < <(echo "${torrent_info}" | jq -r '.[] | select(.state == "error") | .hash') if [[ $? -ne 0 ]]; then EchoE "Failed to identify errored torrents for instance ${instance}." continue fi if [[ ${#errored_hashes[@]} -eq 0 ]]; then Echo "✅ No errored torrents found in instance ${instance}." continue fi Echo "🔄 Found ${#errored_hashes[@]} errored torrents in instance ${instance}..." for hash in "${errored_hashes[@]}"; do torrent_name=$(echo "${torrent_info}" | jq -r --arg h "${hash}" '.[] | select(.hash == $h) | .name') if [[ $? -ne 0 ]]; then EchoE "Failed to retrieve name for torrent ${hash}." continue fi Echo "[RECHECK] ${hash} [${torrent_name}]" if [[ ${DRY_RUN} -eq 0 ]]; then curl --silent --fail -X POST -H "Referer: http://localhost:${port}" --data "hashes=${hash}" "http://localhost:${port}/api/v2/torrents/recheck" if [[ $? -eq 0 ]]; then ((total_rechecked++)) fi fi done done # Output summary if [[ ${DRY_RUN} -eq 1 ]]; then Echo "📊 [DRY-RUN] Summary: ${#errored_hashes[@]} errored torrents would be rechecked" else Echo "📊 Summary: ${#errored_hashes[@]} errored torrents found, ${total_rechecked} rechecked" fi } #============================================================================== # MoveCompletedTorrents - Moves completed torrents to final directory #------------------------------------------------------------------------------ # Description: # Identifies completed torrents not in their final directory and moves them # using the qBittorrent API setLocation endpoint. # # Globals: # QBT_INSTANCES - Mapping of instance names to ports (RO) # FINAL_DIRS - Mapping of instance names to final directories (RO) # DRY_RUN - Flag to simulate actions (RO) # DEBUG - Flag for debug logging (RO) # VERBOSE - Flag for detailed output (RO) # # Arguments: # None (uses global SELECTED_INSTANCE) # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE` for API failures. # Writes warning messages to stdout via `EchoW` for move failures. # Writes debug messages to stdout via `EchoD` if DEBUG=1. # Writes summary of torrents found and moved. # # Exit Codes: # 0 - Success # # Side Effects: # Makes curl requests to the qBittorrent API. # Modifies torrent save paths in qBittorrent if DRY_RUN=0. # Checks directory existence using `cygpath`. #============================================================================== MoveCompletedTorrents() { local -a instances=() local -i grand_total_moved=0 local instance="" local port="" local final_dir="" local api_url="" local torrent_info="" local -a completed_hashes=() local hash="" local name="" local save_path="" local win_final_dir="" local -i moved=0 local hash_list="" local move_response="" declare -A hash_to_name=() declare -A hash_to_save_path=() Echo "🚚 Moving completed torrents for instance ${SELECTED_INSTANCE}..." if [[ "${SELECTED_INSTANCE}" == "all" ]]; then instances=("${!QBT_INSTANCES[@]}") else instances=("${SELECTED_INSTANCE}") fi for instance in "${instances[@]}"; do port=${QBT_INSTANCES["${instance}"]} final_dir=${FINAL_DIRS["${instance}"]} api_url="http://localhost:${port}/api/v2/torrents/info" Echo "🔎 Checking completed torrents in instance '${instance}' at ${api_url}..." torrent_info=$(curl --silent "${api_url}") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrent info for instance '${instance}'." continue fi completed_hashes=() hash_to_name=() hash_to_save_path=() while IFS=$'\t' read -r hash name save_path; do [[ -n "${hash}" && -n "${name}" ]] && { completed_hashes+=("${hash}") hash_to_name["${hash}"]="${name}" hash_to_save_path["${hash}"]="${save_path}" } done < <(RunJq "-r --arg dest \"${final_dir}\" '.[] | select(.progress == 1 and (.save_path | test(\$dest) | not) and (.state != \"moving\")) | [.hash, .name, .save_path] | @tsv'" "${torrent_info}") if [[ $? -ne 0 ]]; then EchoE "Failed to identify completed torrents for instance '${instance}'." continue fi if [[ ${#completed_hashes[@]} -eq 0 ]]; then Echo "✅ No completed torrents to move in instance '${instance}'." continue fi Echo "🚚 Found ${#completed_hashes[@]} completed torrents to move in instance '${instance}'..." moved=0 hash_list="" for hash in "${completed_hashes[@]}"; do name="${hash_to_name[${hash}]}" save_path="${hash_to_save_path[${hash}]}" win_final_dir=$(cygpath -w "${final_dir}" | sed 's/\\/\\\\/g') # Check if source directory exists if [[ ! -d "${save_path}" ]]; then EchoW "Torrent ${hash} [${name}] is in a non-existent directory: ${save_path}" continue fi (( DEBUG )) && EchoD "Torrent ${hash} [${name}] current save_path: ${save_path}" Echo "[MOVE] ${hash} [${name}] ${save_path} → ${win_final_dir}" if [[ -z "${hash_list}" ]]; then hash_list="${hash}" else hash_list="${hash_list}|${hash}" fi done if [[ ${DRY_RUN} -eq 0 && -n "${hash_list}" ]]; then if (( DEBUG )); then EchoD "curl -X POST --data-urlencode \"hashes=${hash_list}\" --data-urlencode \"location=${final_dir}\" http://localhost:${port}/api/v2/torrents/setLocation" move_response=$(curl --verbose -X POST -H "Referer: http://localhost:${port}" \ --data-urlencode "hashes=${hash_list}" \ --data-urlencode "location=${final_dir}" \ "http://localhost:${port}/api/v2/torrents/setLocation") else move_response=$(curl --silent -X POST -H "Referer: http://localhost:${port}" \ --data-urlencode "hashes=${hash_list}" \ --data-urlencode "location=${final_dir}" \ "http://localhost:${port}/api/v2/torrents/setLocation") fi if [[ $? -eq 0 ]]; then moved=${#completed_hashes[@]} else EchoW "Failed to move torrents to ${win_final_dir}. Response: ${move_response}" moved=0 fi fi if [[ ${DRY_RUN} -eq 1 ]]; then Echo "📊 [DRY-RUN] Instance '${instance}': ${#completed_hashes[@]} completed torrents would be moved" else Echo "📊 Instance '${instance}': ${#completed_hashes[@]} found, ${moved} moved" fi (( grand_total_moved += moved )) done if [[ ${DRY_RUN} -eq 0 ]]; then Echo "📦 Total moved across all instances: ${grand_total_moved}" fi } #============================================================================== # RemoveTorrentsWithMalformedPath - Deletes torrents with invalid save paths #------------------------------------------------------------------------------ # Description: # Identifies torrents with non-existent save paths and deletes them without # removing associated files. # # Globals: # QBT_INSTANCES - Mapping of instance names to ports (RO) # DRY_RUN - Flag to simulate actions (RO) # VERBOSE - Flag for detailed output (RO) # # Arguments: # None (uses global SELECTED_INSTANCE) # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes warning messages to stdout via `EchoW` for invalid paths. # # Exit Codes: # 0 - Success # # Side Effects: # Makes curl requests to the qBittorrent API. # Deletes torrents in qBittorrent if DRY_RUN=0. # Converts Windows paths to Cygwin paths for validation. #============================================================================== RemoveTorrentsWithMalformedPath() { local -a instances=() local instance="" local port="" local api_url="" local torrent_info="" local -i removed=0 local hash="" local name="" local save_path="" local cyg_path="" Echo "🧹 Removing torrents with malformed or missing paths for instance '${SELECTED_INSTANCE}'..." if [[ "${SELECTED_INSTANCE}" == "all" ]]; then instances=("${!QBT_INSTANCES[@]}") else instances=("${SELECTED_INSTANCE}") fi for instance in "${instances[@]}"; do port="${QBT_INSTANCES[${instance}]}" api_url="http://localhost:${port}/api/v2/torrents/info" torrent_info=$(curl --silent "${api_url}") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrent info for instance ${instance}." continue fi removed=0 while IFS=$'\t' read -r hash name save_path; do [[ -z "${hash}" || -z "${save_path}" ]] && continue if [[ -z "${save_path}" || ! "${save_path}" =~ ^[A-Za-z]:\\ ]]; then EchoW "Invalid save_path for torrent ${hash} [${name}]: '${save_path}'" continue fi cyg_path="${save_path//\\//}" cyg_path="/cygdrive/${cyg_path:0:1}/${cyg_path:3}" if [[ ! -d "${cyg_path}" ]]; then EchoW "[DELETE] ${hash} [${name}] save_path '${save_path}' does not exist" if [[ ${DRY_RUN} -eq 0 ]]; then curl --silent --get --data-urlencode "hashes=${hash}" "http://localhost:${port}/api/v2/torrents/delete?deleteFiles=false" ((removed++)) fi fi done < <(RunJq '-r ".[] | [.hash, .name, .save_path] | @tsv"' "${torrent_info}") if [[ $? -ne 0 ]]; then EchoE "Failed to process torrents for instance '${instance}'." continue fi Echo "🗑️ Instance '${instance}': ${removed} torrents removed with invalid paths" done } #============================================================================== # SetCorrectCategory - Fixes torrent categories based on instance #------------------------------------------------------------------------------ # Description: # Updates torrent categories to match the expected category for the instance # (e.g., 'TV' for tv instance). # # Globals: # QBT_INSTANCES - Mapping of instance names to ports (RO) # DRY_RUN - Flag to simulate actions (RO) # VERBOSE - Flag for detailed output (RO) # # Arguments: # None (uses global SELECTED_INSTANCE) # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes warning messages to stdout via `EchoW` for unknown instances. # # Exit Codes: # 0 - Success # # Side Effects: # Makes curl requests to the qBittorrent API. # Modifies torrent categories in qBittorrent if DRY_RUN=0. #============================================================================== SetCorrectCategory() { local -a instances=() local instance="" local port="" local expected_cat="" local torrent_info="" local -i changed=0 local hash="" local name="" local category="" Echo "🛠️ Fixing torrent categories for instance '${SELECTED_INSTANCE}'..." if [[ "${SELECTED_INSTANCE}" == "all" ]]; then instances=("${!QBT_INSTANCES[@]}") else instances=("${SELECTED_INSTANCE}") fi for instance in "${instances[@]}"; do port="${QBT_INSTANCES[${instance}]}" expected_cat="" case "${instance}" in anime) expected_cat="Anime" ;; xxx) expected_cat="XXX" ;; tv) expected_cat="TV" ;; movie) expected_cat="Movie" ;; *) EchoW "Unknown instance '${instance}', skipping." ; continue ;; esac torrent_info=$(curl --silent "http://localhost:${port}/api/v2/torrents/info") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrents for instance ${instance}." continue fi changed=0 while IFS=$'\t' read -r hash name category; do if [[ "${category}" != "${expected_cat}" ]]; then ((VERBOSE)) && Echo "[FIX] ${hash} [${name}] category '${category}' → '${expected_cat}'" ((DRY_RUN == 0)) && curl --silent --get --data-urlencode "hashes=${hash}" --data-urlencode "category=${expected_cat}" "http://localhost:${port}/api/v2/torrents/setCategory" ((changed++)) if ((VERBOSE == 0 && changed % 50 == 0)); then Echo "🔁 Applied ${changed} category fixes so far..." fi fi done < <(RunJq '-r ".[] | [.hash, .name, .category] | @tsv"' "${torrent_info}") if [[ $? -ne 0 ]]; then EchoE "Failed to process torrents for instance '${instance}'." continue fi Echo "✅ Instance '${instance}': ${changed} torrent categories fixed" done } #============================================================================== # MoveIncompleteTorrentsToDownloadFolder - Moves incomplete torrents to temp folder #------------------------------------------------------------------------------ # Description: # Ensures incomplete torrents are in the correct temporary download directory # for the instance using the qBittorrent API setLocation endpoint. # # Globals: # QBT_INSTANCES - Mapping of instance names to ports (RO) # TEMP_DIRS - Mapping of instance names to temp directories (RO) # DRY_RUN - Flag to simulate actions (RO) # DEBUG - Flag for debug logging (RO) # # Arguments: # None (uses global SELECTED_INSTANCE) # # Outputs: # Writes informational messages to stdout via `Echo`. # Writes error messages to stdout via `EchoE` for API failures. # Writes warning messages to stdout via `EchoW` for move failures. # Writes debug messages to stdout via `EchoD` if DEBUG=1. # # Exit Codes: # 0 - Success # # Side Effects: # Makes curl requests to the qBittorrent API. # Modifies torrent save paths in qBittorrent if DRY_RUN=0. # Checks directory mappings using `cygpath`. #============================================================================== MoveIncompleteTorrentsToDownloadFolder() { local -a instances=() local -i total_moved=0 local instance="" local port="" local api_url="" local expected_dir="" local torrent_info="" local -i moved=0 local hash="" local name="" local save_path="" local state="" local win_expected_dir="" local hash_list="" local move_response="" Echo "📦 Ensuring incomplete torrents are in the correct temp folder for instance ${SELECTED_INSTANCE}..." if [[ "${SELECTED_INSTANCE}" == "all" ]]; then instances=("${!QBT_INSTANCES[@]}") else instances=("${SELECTED_INSTANCE}") fi for instance in "${instances[@]}"; do port=${QBT_INSTANCES["${instance}"]} api_url="http://localhost:${port}/api/v2/torrents/info" expected_dir="${TEMP_DIRS[${instance}]}" [[ -z "${expected_dir}" ]] && { EchoW "No temp download dir defined for instance '${instance}'"; continue; } Echo "🔍 Scanning incomplete torrents in instance '${instance}' at ${api_url}..." torrent_info=$(curl --silent "${api_url}") if [[ -z "${torrent_info}" ]]; then EchoE "Failed to fetch torrent info for instance '${instance}'." continue fi moved=0 hash_list="" while IFS=$'\t' read -r hash name save_path state; do win_expected_dir=$(cygpath -w "${expected_dir}" | sed 's/\\/\\\\/g') Echo "[MOVE] ${hash} [${name}] ${save_path} → ${win_expected_dir}" if [[ -z "${hash_list}" ]]; then hash_list="${hash}" else hash_list="${hash_list}|${hash}" fi done < <(RunJq "-r --arg temp \"${expected_dir}\" '.[] | select(.progress < 1 and .state != \"moving\" and .state != \"checkingUP\" and .state != \"checkingDL\" and (.save_path | test(\$temp) | not)) | [.hash, .name, .save_path, .state] | @tsv'" "${torrent_info}") if [[ $? -ne 0 ]]; then EchoE "Failed to process incomplete torrents for instance '${instance}'." continue fi if [[ ${DRY_RUN} -eq 0 && -n "${hash_list}" ]]; then if (( DEBUG )); then EchoD "curl -X POST --data-urlencode \"hashes=${hash_list}\" --data-urlencode \"location=${expected_dir}\" http://localhost:${port}/api/v2/torrents/setLocation" move_response=$(curl --verbose -X POST -H "Referer: http://localhost:${port}" \ --data-urlencode "hashes=${hash_list}" \ --data-urlencode "location=${expected_dir}" \ "http://localhost:${port}/api/v2/torrents/setLocation") else move_response=$(curl --silent -X POST -H "Referer: http://localhost:${port}" \ --data-urlencode "hashes=${hash_list}" \ --data-urlencode "location=${expected_dir}" \ "http://localhost:${port}/api/v2/torrents/setLocation") fi if [[ $? -eq 0 ]]; then moved=$(echo "${hash_list}" | tr '|' '\n' | wc -l) else EchoW "Failed to move torrents to ${win_expected_dir}. Response: ${move_response}" moved=0 fi fi if [[ ${DRY_RUN} -eq 1 ]]; then Echo "[DRY-RUN] Instance '${instance}': ${moved} incomplete torrents would be moved to ${expected_dir}" else Echo "Instance '${instance}': ${moved} incomplete torrents moved to ${expected_dir}" fi (( total_moved += moved )) done if [[ ${DRY_RUN} -eq 0 ]]; then Echo "📦 Total incomplete torrents relocated: ${total_moved}" fi } #============================================================================== # RunSanitizationSuite - Orchestrates full torrent sanitization #------------------------------------------------------------------------------ # Description: # Runs a sequence of sanitization tasks: removes malformed torrents, fixes # categories, moves completed torrents, and relocates incomplete torrents. # # Globals: # None (relies on functions called) # # Arguments: # None (uses global SELECTED_INSTANCE) # # Outputs: # Writes informational messages to stdout via `Echo`. # # Exit Codes: # 0 - Success # # Side Effects: # Calls `RemoveTorrentsWithMalformedPath`, `SetCorrectCategory`, # `MoveCompletedTorrents`, and `MoveIncompleteTorrentsToDownloadFolder`. # Inherits side effects of called functions (API calls, file modifications). #============================================================================== RunSanitizationSuite() { local -r SELECTED_INSTANCE="${INSTANCE_NAME}" Echo "🧽 Running full torrent sanitization on instance '${SELECTED_INSTANCE}'..." RemoveTorrentsWithMalformedPath SetCorrectCategory MoveCompletedTorrents MoveIncompleteTorrentsToDownloadFolder Echo "✅ Sanitization complete." } #============================================================================== # Main - Orchestrates script execution #------------------------------------------------------------------------------ # Description: # Parses arguments, checks dependencies, validates qBittorrent reachability, # and routes execution to the appropriate mode-specific function. # # Globals: # EXECUTION_MODE - Selected execution mode (RO) # INSTANCE_NAME - Selected qBittorrent instance (RO) # # Arguments: # $@ - Command-line arguments # # Outputs: # Writes error messages to stdout via `EchoE` for invalid modes or instances. # # Exit Codes: # 0 - Success # 1 - Failure (invalid mode, missing instance, dependency issues) # # Side Effects: # Calls `ParseArguments`, `DoDependency`, `CheckQbtReachability`, and # mode-specific functions (`DoDelete`, `DoRecheck`, etc.). # Inherits side effects of called functions (API calls, file modifications). #============================================================================== Main() { if ParseArguments "$@"; then # Validate execution mode if [[ ${EXECUTION_MODE} -eq ${MODE_NONE} ]]; then EchoE "No execution mode specified. Use --delete, --recheck-error, --set-priority, or --manage-downloads." ShowHelp return 1 fi # Check dependencies DoDependency if [[ -z "${INSTANCE_NAME}" ]]; then EchoE "--instance is required for --recheck-error mode." ShowHelp return 1 fi # Check qBittorrent reachability (except for dependency check mode) if [[ ${EXECUTION_MODE} -ne ${MODE_NONE} ]]; then CheckQbtReachability fi # Execute based on mode case ${EXECUTION_MODE} in ${MODE_DELETE}) DoDelete "${INSTANCE_NAME}";; ${MODE_RECHECK}) DoRecheck "${INSTANCE_NAME}";; ${MODE_PRIORITY}) DoSetPriority "${INSTANCE_NAME}";; ${MODE_REMOVE_MALFORMED}) RemoveTorrentsWithMalformedPath "${INSTANCE_NAME}";; ${MODE_FIX_CATEGORY}) SetCorrectCategory "${INSTANCE_NAME}";; ${MODE_MOVE_COMPLETED}) MoveCompletedTorrents "${INSTANCE_NAME}";; ${MODE_MOVE_INCOMPLETE}) MoveIncompleteTorrentsToDownloadFolder "${INSTANCE_NAME}";; ${MODE_SANITISE}) RemoveTorrentsWithMalformedPath "${INSTANCE_NAME}";; esac fi } Main "$@" exit 0 # ----------------------------------------------------------------------------- # __CHATGPT_INSTRUCTIONS_BEGIN__ # # All Bash functions in this script must include a header block with the following format: # #============================================================================== # <function-name> - <short description of what the function does> # Globals: # <VARIABLE_NAME> - Description of its use (RO for read-only, RW for read-write) # ... # Arguments: # $1 - Description of the first positional argument # $2 - Description of the second positional argument (if applicable) # ... # Outputs: # <Clearly state what is written to stdout or stderr> # <Any global variables modified (e.g., sets ACTOR_FILMOGRAPHY)> #============================================================================== # # Guidelines: # - Always use `local` for function-local variables. # - Use `${...}` consistently for all variable references. # - Output user-facing messages using the Echo, EchoE, EchoW, EchoD functions. # - When writing a function that returns data via stdout, prefer using a dedicated file descriptor (e.g., `>&5`) to avoid collisions with logging. # - Each function should be self-contained and document every global variable it accesses or modifies. # - Always wrap long `jq` or `curl` commands in clean, readable syntax with fallbacks or clear error handling. # # Logging functions are predefined and must be used instead of raw `echo`. # Each automatically includes a timestamp, an icon, and a log-level prefix. # # Use the following functions consistently: # # - `Echo` → for normal informational output (✅ or 📌) # - `EchoW` → for warnings (⚠️ WARNING:) # - `EchoE` → for errors (❌ ERROR:) # - `EchoD` → for debug output (🛠 DEBUG:) # # Important: # - Do **NOT** prepend messages manually with "ERROR:", "WARNING:", or emojis — these are automatically included by the functions. # - Do **NOT** call `echo` directly for user-facing output unless it's data being piped, stored, or exported. # - All log messages should be clear, human-readable, and aligned with script actions. # # Examples (✅ correct): # Echo "✅ Submitted successfully." # icon can be supplied # EchoW "Actor already exists in the database." # EchoE "Could not resolve TMDb ID for IMDb ID '${id}'." # EchoD "Parsed movie title: ${title}" # # Examples (❌ incorrect): # EchoW "WARNING: Actor exists." # redundant prefix # EchoE "❌ ERROR: Failed to post." # message is duplicated # EchoD "2025-04-22 00:51:35| Parsed movie title: ${title}" # message havs two time stamps # # 1. **Purpose and Modes**: # - The script supports four modes: --delete, --recheck-error, --set-priority, and --manage-downloads. # - --delete: Deletes torrents matching patterns in TV_Organize_Patterns.txt. # - --recheck-error: Rechecks torrents in an "error" state for a specific qBittorrent instance. # - --set-priority: Sets high priority and force-starts TV torrents matching specific prefixes. # - --manage-downloads: Moves completed torrents to a final directory and ensures incomplete ones are in a temp directory. # # 2. **Key Variables**: # - PREFIXES: Array of TV show prefixes used for matching in --set-priority mode. # - QBT_INSTANCES: Associative array mapping instance names (tv, anime, etc.) to qBittorrent ports. # - TEMP_DIRS/FINAL_DIRS: Directory mappings for --manage-downloads mode. # - DRY_RUN: Flag to simulate actions (default: 1, enabled). # # 3. **Dependencies**: # - Requires curl, jq, date, and urlencode (with fallbacks for urlencode). # - Dependency checks are configurable via --check-deps, --check-deps-local, --check-deps-install. # # 4. **Logging and Debugging**: # - Use --debug (-D) to enable verbose output, including curl commands and intermediate data. # - Logs are written to /cygdrive/f/p_qBittorrent/.logs for reachability and failures. # # 5. **Error Handling**: # - The script includes reachability checks for qBittorrent with retries. # - Temporary files are cleaned up on exit (via trap). # - API responses are checked for failures. # # 6. **Potential Improvements**: # - Add support for more qBittorrent API endpoints (e.g., pausing/resuming torrents). # - Enhance pattern matching with regex options in --delete mode. # - Add a --force option to skip user prompts in --delete mode. # # 7. **Known Issues**: # - jq 1.7.1 has a bug causing assertion failures with complex expressions. Workarounds include simplifying jq usage or upgrading to a newer version. # - Large torrent lists may cause performance issues; consider batching API calls. # # __CHATGPT_INSTRUCTIONS_END__ # -----------------------------------------------------------------------------
Comments
Post a Comment