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

Movie - Bastille Day | The Take (2016)

  My views Plot On the eve of  Bastille Day  in  Paris , American drifter and  pickpocket  Michael Mason steals a woman's handbag, not knowing that it contains  explosives . After taking the cash from the bag, he discards it, caught unwittingly on  CCTV  as he does so. The bag then detonates and kills four people. Upon being captured by  CIA  agent Sean Briar, who is being reprimanded for irresponsible conduct on the job, Mason protests that he is not a  terrorist  and tells Briar that the bag contained a cellphone owned by a woman named Zoé. The bomb was set up by a group of  corrupt policemen , all of them members in the  French special police RAPID unit  led by Rafi Bertrand, who intend to pull a robbery at the  Bank of France . Zoé was told to plant the bomb at the office of the  French Nationalist Party  (as part of a diversion for the heist), but after seeing the night cleaning crew arri...

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 ...

Movie - The Wizard of Oz (1939)

  My views Plot In rural  Kansas ,  Dorothy Gale  lives on a farm owned by her Uncle Henry and Aunt Em, and wishes she could be somewhere else. Dorothy's neighbor, Almira Gulch, who had been bitten by Dorothy's dog, Toto, obtains a sheriff's order authorizing her to seize Toto. Toto escapes and returns to Dorothy, who runs away to protect him. Professor Marvel, a charlatan fortune-teller, convinces Dorothy that Em is heartbroken, which prompts Dorothy to return home. She returns just as a  tornado  approaches the farm. Unable to get into the locked storm cellar, Dorothy takes cover in the farmhouse and is knocked unconscious. She seemingly awakens to find the house moving through the air, with her and Toto still inside it. The house comes down in an unknown land, and Dorothy is greeted by a good witch named  Glinda , who floats down in a bubble and explains that Dorothy has landed in Munchkinland in the  Land of Oz , and that the Munchkins are cel...