Skip to main content

IT - Programming - BASH scripting - qBittorrents_StatusMonitor.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

Movies - Deadpool & Wolverine (2024)

 

TV Mini-serie - Lady in the Lake (2024)

 

Movie - The Gorge (2025)

  My views For 80 years everything was ok ... until they sent a woman For sure is DTV ... really bad Inside the gorge is clearly designed and written by a gamer Plot Two elite  snipers  receive identical missions: travel to an undisclosed location and guard the West and East sides of a deep gorge for one year without any contact with the outside world nor their counterpart on the opposite side. Levi Kane, a former  U.S. Marine  and current  private contractor  accepts the offer to guard the West tower. Drasa, a  Lithuanian  covert operative frequently employed by the  Kremlin , agrees to guard the East side. Upon arriving, Levi relieves his predecessor, J.D., a  British   Royal Marine  of duty and asks for specifics about the mission. J.D. explains that in addition to the towers on the East and West, there are automated turret defenses to the North and South, a powerful signal ‘ cloak ,’ and  explosives on the walls ...