#!/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
Post a Comment