Skip to main content

IT - Programming - BASH scripting - QbittorrentStatusMonitor.sh

#!/bin/bash
# 🧠 ChatGPT-aware developer instructions at bottom of script (__CHATGPT_INSTRUCTIONS_BEGIN__)
# Published: https://afberendsen.blogspot.com/2025/04/it-programming-bash-script.html
################################################################################
# Script Name: QbittorrentStatusMonitor.sh
# Description : Monitors multiple qBittorrent instances running in parallel, 
#               aggregating torrent statistics (Active, Checking, Moving, Completed)
#               across isolated categories: Anime, Movie, TV, and XXX.
#
# Output      : Prints a visually aligned, column-based table every 30 minutes 
#               showing per-category and total counts. Automatically reprints 
#               headers every 20 rows. Logs can be redirected if needed.
#
# Requirements: - qBittorrent Web UI enabled on each profile with API access
#               - curl, jq installed
#               - Cygwin-compatible (Unix-like Bash shell on Windows)
#
# Usage       : Run in the background or via systemd/task scheduler:
#               $ nohup ./QbittorrentStatusMonitor.sh &
#
# Version History:
#   v1.0  - Initial working prototype with fixed ports and hardcoded URLs
#   v1.1  - Added structured header printing and improved output alignment
#   v1.2  - Introduced category-based aggregation logic (Anime, Movie, TV, XXX)
#   v1.3  - Switched to dynamic category/port maps using CATEGORIES[] and QBT_PORTS[]
#   v1.4  - Improved JSON fetch safety by using temporary variables
#   v1.5  - Added jq-based filtering for consistent state detection per category
#   v1.6  - Included 'Total' columns for Active, Checking, Moving, and Completed
#   v1.7  - Gracefully handled empty or invalid API responses with fallbacks
#   v1.8  - Refactored state-counting into modular helper functions
#   v1.9  - Centralized category/port configuration for easier scalability
#   v1.10 - Added command-line options: --help (-h), --version (-V)
#   v1.11 - Added --interval (-i) to set polling rate, and --count (-c) for header frequency
#   v1.12 - Implemented retry logic (default 5 tries with 5s delay) for transient API failures
#   v1.13 - Added --try and --delay switches for user-defined retry attempts and delay
#   v1.14 - Introduced '--' as a command-line switch terminator to stop further option parsing
#
# Author      : afberendsen + ChatGPT
# Date        : 2025-04-17
################################################################################

# Load shared library
[[ ! -e ~/MY_LIBRARY.sh ]] && { echo "***ERROR: Cannot find library" ; exit 1; }
[[ ! -r ~/MY_LIBRARY.sh ]] && { echo "***ERROR: Library is not readable" ; exit 1; }
source ~/MY_LIBRARY.sh || { echo "***ERROR: Cannot source library" ; exit 1; }

[[ ! -e ~/MY_SECRETS.sh ]] && { echo "***ERROR: Cannot find ~/MY_SECRETS.sh" ; exit 1; }
[[ ! -r ~/MY_SECRETS.sh ]] && { echo "***ERROR: ~/MY_SECRETS.sh is not readable" ; exit 1; }
source ~/MY_SECRETS.sh || { echo "***ERROR: Cannot source ~/MY_SECRETS.sh" ; exit 1; }

trap 'Echo "💥 Caught interrupt signal, exiting."; exit 1' SIGINT

#==============================================================================
# Constants
#==============================================================================
readonly SCRIPT_VERSION="1.14"
declare -r LOG_FILE="./qbittorrent_status.log"

# CONFIGURATION
declare -ar CATEGORIES=("Anime" "Movie" "TV" "XXX")
declare -Ar QBT_PORTS=(["Anime"]=8079 ["Movie"]=8076 ["TV"]=8077 ["XXX"]=8078)
declare -A QBT_URLS=()
declare -i INTERVAL_MINUTES=30
declare -i INTERVAL_HEADER=20
declare -i TIMEOUT_TRY=10
declare -i TIMEOUT_DELAY=5

# Initialize API URLs
for cat in "${CATEGORIES[@]}"; do
  QBT_URLS["${cat}"]="http://localhost:${QBT_PORTS[$cat]}/api/v2/torrents/info"
done

#==============================================================================
# Show help
#==============================================================================
show_help() {
  cat <<EOF
Usage: ${0##*/} [OPTIONS] [--]

Monitors multiple qBittorrent instances and prints torrent statistics.

Options:
  -i, --interval <minutes>   Set polling interval (default: 30)
  -c, --count <lines>        Rows between header lines (default: 20)
  -t, --try <number>         Retry attempts for failed API calls (default: 5)
  -d, --delay <seconds>      Delay between retries in seconds (default: 5)
  -h, --help                 Show this help message and exit
  -V, --version              Show script version and exit
  --                         Stop processing further switches

Each qBittorrent instance must listen on:
  Anime : http://localhost:8079
  Movie : http://localhost:8076
  TV    : http://localhost:8077
  XXX   : http://localhost:8078
EOF
}

#==============================================================================
#==============================================================================
OLD_print_header() {
  printf "+---------------------+--------+---------------------------------------+---------------------------------------+----------------------------------------------------------------------+\n"
  printf "|                     | _____________Active__________________ | ______________Checking_______________ | _______________Moving________________ | ______________Completed______________ |\n"
  printf "| Timestamp           | Anime | Movie |    TV |   XXX | Total | Anime | Movie |    TV |   XXX | Total | Anime | Movie |    TV |   XXX | Total | Anime | Movie |    TV |   XXX | Total |\n"
  printf "+---------------------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+-------+\n"
  nRowCounter=0
}

#==============================================================================
# Dynamic top/bottom border line
#==============================================================================
print_separator_line() {
  printf "+---------------------+"
  for _ in "${states[@]}"; do
    for _ in "${CATEGORIES[@]}"; do printf '%s' "-------+"; done
    printf '%s' "-------+"
  done
  printf "\n"
}

#==============================================================================
#==============================================================================
print_header() {
  local -ir col_width=5
  local -ir col_total_width=$((col_width + 3))  # One space + number + +one space + pipe = 8
  local -i block_width
  local -r states=("Active" "Checking" "Moving" "Completed")

  print_separator_line

  # STATE ROW (Active | Checking | ...)
  printf "| %-19s |" ""
  for state in "${states[@]}"; do
    block_width=$(( (${#CATEGORIES[@]} + 1) * col_total_width - 1 )) # -1 is for the last '|'
    label_len=${#state}
    padding=$((block_width - label_len))
    left_padding=$((padding / 2))
    right_padding=$((padding - left_padding))
    # Left padding: 1 space + underscores
    printf ' %*s'  "$((left_padding - 1))"  "$(printf '%*s' "$((left_padding - 1))" | tr ' ' '_')" 
    printf '%s'    "${state}"
    printf '%*s |' "$((right_padding - 1))" "$(printf '%*s' "$((right_padding - 1))" | tr ' ' '_')"
  done
  echo

  # COLUMN NAMES (Anime | Movie | ... | Total)
  printf "| %-19s |" "Timestamp"
  for _ in "${states[@]}"; do
    for cat in "${CATEGORIES[@]}"; do
      printf " %5s |" "${cat}"
    done
    printf " %5s |" "Total"
  done
  echo

  print_separator_line
  nRowCounter=0
}

#==============================================================================
#==============================================================================
print_row() {
  local -r timestamp="${1}"
  shift
  local -a values=( "$@" )
  [[ ${nRowCounter} -ge ${INTERVAL_HEADER} ]] && print_header
  printf "| %-19s |" "${timestamp}"
  for i in {0..4}; do printf " %5s |" "${values[$i]}" ; done  # Total
  for i in {5..9}; do printf " %5s |" "${values[$i]}" ; done  # Checking
  for i in {10..14}; do printf " %5s |" "${values[$i]}" ; done  # Moving
  for i in {15..19}; do printf " %5s |" "${values[$i]}" ; done  # Completed
  echo
  (( nRowCounter++ ))
}

#==============================================================================
#==============================================================================
get_state_count() {
  local json="${1}" category="${2}" state="${3}"
  echo "${json}" | jq --arg cat "${category}" --arg state "${state}" \
    '[.[] | select(.category == $cat and .state == $state)] | length'
}

#==============================================================================
#==============================================================================
get_completed_count() {
  local json="${1}" category="${2}"
  echo "${json}" | jq --arg cat "${category}" \
    '[.[] | select(.category == $cat and (.state == "uploading" or .state == "stalledUP" or .state == "pausedUP" or .state == "queuedUP"))] | length'
}

#==============================================================================
#==============================================================================
ParseArguments() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -i|--interval)
        shift
        [[ "$1" =~ ^[0-9]+$ ]] || { echo "ERROR: --interval must be a number"; exit 1; }
        INTERVAL_MINUTES="$1"
        ;;
      -c|--count)
        shift
        [[ "$1" =~ ^[0-9]+$ ]] || { echo "ERROR: --count must be a number"; exit 1; }
        INTERVAL_HEADER="$1"
        ;;
      -t|--try)
        shift
        [[ "$1" =~ ^[0-9]+$ ]] || { echo "ERROR: --try must be a number"; exit 1; }
        TIMEOUT_TRY="$1"
        ;;
      -d|--delay)
        shift
        [[ "$1" =~ ^[0-9]+$ ]] || { echo "ERROR: --delay must be a number"; exit 1; }
        TIMEOUT_DELAY="$1"
        ;;
      -h|--help)
        show_help
        exit 0
        ;;
      -V|--version)
        echo "${0##*/} version ${SCRIPT_VERSION}"
        exit 0
        ;;
      --) shift; break ;;  # Stop parsing further options
      -*)
        echo "Unknown option: $1"
        show_help
        exit 1
        ;;
      *) break ;;  # Stop if positional argument encountered
    esac
    shift
  done
}

#==============================================================================
#==============================================================================
Main() {
  ParseArguments "$@"

  declare -ir INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60))
  declare -i nRowCounter="${INTERVAL_HEADER}" # Force first header

  while true; do
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

    declare -A JSONS 
    declare -Ai TOTALS CHECKINGS MOVINGS COMPLETEDS
    declare -i TOTAL=0 CHECKING=0 MOVING=0 COMPLETED=0

    for cat in "${CATEGORIES[@]}"; do
      temp_json=$(mktemp)
      declare attempt=1 success=0

      while (( attempt <= TIMEOUT_TRY )); do
        curl --silent --max-time 10 "${QBT_URLS[$cat]}" > "${temp_json}"
        if jq -e . "${temp_json}" &>/dev/null; then
          success=1
          break
        fi
        (( attempt++ ))
        sleep "${TIMEOUT_DELAY}"
      done

      if (( success == 0 )); then
        EchoW "⚠️ Invalid JSON from ${QBT_URLS[$cat]} after ${TIMEOUT_TRY} tries – using empty list"
        JSONS["$cat"]="[]"
      else
        JSONS["$cat"]="$(< "${temp_json}")"
      fi
      rm -f "${temp_json}"

      TOTALS["$cat"]="$(echo "${JSONS[$cat]}" | jq length 2>/dev/null || echo 0)"
      CHECKINGS["$cat"]="$(get_state_count    "${JSONS[$cat]}" "${cat}" "checkingResumeData" 2>/dev/null || echo 0)"
      MOVINGS["$cat"]="$(get_state_count     "${JSONS[$cat]}" "${cat}" "moving" 2>/dev/null || echo 0)"
      COMPLETEDS["$cat"]="$(get_completed_count "${JSONS[$cat]}" "${cat}" 2>/dev/null || echo 0)"

      (( TOTAL     += TOTALS[$cat] ))
      (( CHECKING  += CHECKINGS[$cat] ))
      (( MOVING    += MOVINGS[$cat] ))
      (( COMPLETED += COMPLETEDS[$cat] ))
    done

    print_row "${TIMESTAMP}" \
      "${TOTALS[Anime]}" "${TOTALS[Movie]}" "${TOTALS[TV]}" "${TOTALS[XXX]}" "${TOTAL}" \
      "${CHECKINGS[Anime]}" "${CHECKINGS[Movie]}" "${CHECKINGS[TV]}" "${CHECKINGS[XXX]}" "${CHECKING}" \
      "${MOVINGS[Anime]}" "${MOVINGS[Movie]}" "${MOVINGS[TV]}" "${MOVINGS[XXX]}" "${MOVING}" \
      "${COMPLETEDS[Anime]}" "${COMPLETEDS[Movie]}" "${COMPLETEDS[TV]}" "${COMPLETEDS[XXX]}" "${COMPLETED}"

    sleep "${INTERVAL_SECONDS}"
  done
}

Main "$@"
exit 0

# __CHATGPT_INSTRUCTIONS_BEGIN__

# ➤ Versioning Policy
#   - Always increment versions sequentially (e.g., 1.9 → 1.10 → 1.11).
#   - DO NOT bump major version (1.x → 2.0) unless explicitly requested by the user.

# ➤ Platform Check
#   - Every script must include this as the FIRST EXECUTABLE line:
#     [[ "$(uname -r)" != CYGWIN_NT* ]] && echo "This script can only be executed under Cygwin" && exit

# ➤ Secrets and Tokens
#   - NEVER embed credentials or API keys directly in the script.
#   - Place all such constants inside ~/MY_SECRETS.sh and load them with:
#     source ~/MY_SECRETS.sh

# ➤ Function Naming Convention
#   - Functions must be in PascalCase (FirstLetterUppercase, no underscores).
#     e.g., ParseArguments, DoRecheckErrored, SubmitPostToBlogger

# ➤ Script Header Formatting
#   - Use a clear comment block with:
#     - Script name
#     - Version history (incremental)
#     - Purpose summary
#     - Author or ChatGPT note if applicable

# ➤ Output Conventions
#   - Use Echo / EchoD / EchoW / EchoE from ~/MY_LIBRARY.sh
#   - Echo messages must include timestamps and emojis if relevant

# ➤ Argument Parsing
#   - Use getopt-like flags (e.g., -v, -d, --execute)
#   - Mutually exclusive flags (e.g., --delete, --set-priority, --recheck-error) must be enforced

# ➤ Return Values from Functions
#   - Use a dedicated FD (e.g., >&5) when a function returns structured data

# ➤ Looping Style
#   - Prefer while ... done < <(...) over piping for clarity and control

# ➤ Default Flag Expectations
#   - Always support: -h|--help, -V|--version, -n|--dry-run, -v|--verbose, -D|--debug

# ➤ Caching and Metadata
#   - Store all cache, mappings, or temporary state under ~/.MyBlogCache or ~/.MyScriptCache

# ➤ Blogger and OMDb/TMDb Integration
#   - Respect post format rules (titles, actor pages, movie formatting)
#   - Use both IMDb ID and actor name when available

# ➤ Blog Post Templates
#   - Actor posts must include: photo, role table, source list, biography
#   - Movie posts must support: collapsible cast, poster, runtime, metadata, labels

# ➤ Reserved Directories
#   - Use /cygdrive/f/p_qBittorrent/.cache/ for qBittorrent logs/backups
#   - Use /cygdrive/p/MovieLibrary or /cygdrive/f/p_BlogCache/ for media/blog scripts

# __CHATGPT_INSTRUCTIONS_END__

Comments

Popular posts from this blog

TV Series - The Brokenwood Mysteries [NZ] (2014) - Season 10

 

Movie - Sin City: A Dame to Kill For (2014)

 

Movies - Deadpool & Wolverine (2024)