Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save dougrathbone/e39a49b4a1a6e090c11f21abb4d930b2 to your computer and use it in GitHub Desktop.

Select an option

Save dougrathbone/e39a49b4a1a6e090c11f21abb4d930b2 to your computer and use it in GitHub Desktop.
Seerr stalled download fixer
#!/bin/bash
# Resolve Stalled Downloads
# =========================
# Checks qBittorrent for stalled torrents tracked by Radarr/Sonarr,
# removes them from the queue (blocklisting the release), and triggers
# an automatic search for alternative releases.
#
# Usage:
# ./resolve-stalled-downloads.sh # normal run
# ./resolve-stalled-downloads.sh --dry-run # preview only, no changes
#
# Environment variables (override defaults):
# QBITTORRENT_URL - qBittorrent API URL (default: http://localhost:8080)
# QBITTORRENT_USER - qBittorrent username (default: admin)
# QBITTORRENT_PASS - qBittorrent password (default: empty)
# RADARR_URL - Radarr API URL (default: http://localhost:7878)
# RADARR_API_KEY - Radarr API key (required if not auto-detected)
# SONARR_URL - Sonarr API URL (default: http://localhost:8989)
# SONARR_API_KEY - Sonarr API key (required if not auto-detected)
# MIN_STALL_TIME - Minimum seconds stalled before acting (default: 3600)
set -euo pipefail
# --- Configuration ---
QBITTORRENT_URL="${QBITTORRENT_URL:-http://localhost:8080}"
QBITTORRENT_USER="${QBITTORRENT_USER:-admin}"
QBITTORRENT_PASS="${QBITTORRENT_PASS:-}"
RADARR_URL="${RADARR_URL:-http://localhost:7878}"
RADARR_API_KEY="${RADARR_API_KEY:-}"
SONARR_URL="${SONARR_URL:-http://localhost:8989}"
SONARR_API_KEY="${SONARR_API_KEY:-}"
MIN_STALL_TIME="${MIN_STALL_TIME:-3600}"
DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log() { echo "[$TIMESTAMP] $*"; }
info() { log "INFO $*"; }
warn() { log "WARN $*"; }
err() { log "ERROR $*"; }
# --- Auto-detect API keys from Docker containers if not set ---
auto_detect_api_key() {
local container="$1"
if docker inspect "$container" &>/dev/null; then
docker exec "$container" cat /config/config.xml 2>/dev/null \
| grep -oP '(?<=<ApiKey>)[^<]+' || true
fi
}
if [[ -z "$RADARR_API_KEY" ]]; then
RADARR_API_KEY=$(auto_detect_api_key "radarr")
if [[ -z "$RADARR_API_KEY" ]]; then
err "Could not detect Radarr API key. Set RADARR_API_KEY env var."
exit 1
fi
fi
if [[ -z "$SONARR_API_KEY" ]]; then
SONARR_API_KEY=$(auto_detect_api_key "sonarr")
if [[ -z "$SONARR_API_KEY" ]]; then
err "Could not detect Sonarr API key. Set SONARR_API_KEY env var."
exit 1
fi
fi
# --- Dependency check ---
for cmd in curl python3 docker; do
if ! command -v "$cmd" &>/dev/null; then
err "Required command not found: $cmd"
exit 1
fi
done
# --- Get stalled torrents from qBittorrent ---
info "Checking qBittorrent for stalled downloads..."
STALLED_JSON=$(curl -sf "${QBITTORRENT_URL}/api/v2/torrents/info?filter=stalled_downloading" \
--cookie-jar /tmp/qbt_cookie \
-d "username=${QBITTORRENT_USER}&password=${QBITTORRENT_PASS}" 2>/dev/null || \
curl -sf "${QBITTORRENT_URL}/api/v2/torrents/info?filter=stalled_downloading" 2>/dev/null || echo "[]")
STALLED_HASHES=$(echo "$STALLED_JSON" | python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
for t in data:
# Only include incomplete torrents (< 100% progress)
if t['progress'] < 1.0:
print(t['hash'].lower())
except:
pass
" 2>/dev/null)
STALLED_COUNT=$(echo "$STALLED_HASHES" | grep -c . 2>/dev/null || echo 0)
if [[ "$STALLED_COUNT" -eq 0 ]]; then
info "No stalled incomplete downloads found. Nothing to do."
exit 0
fi
info "Found $STALLED_COUNT stalled incomplete torrent(s) in qBittorrent"
# Print stalled torrent details
echo "$STALLED_JSON" | python3 -c "
import json, sys
data = json.load(sys.stdin)
for t in data:
if t['progress'] < 1.0:
pct = t['progress'] * 100
print(f\" - {t['name'][:80]} [{pct:.1f}% | {t['num_seeds']}S {t['num_leechs']}L]\")
" 2>/dev/null
# --- Process Radarr queue ---
process_arr_queue() {
local service_name="$1"
local arr_url="$2"
local arr_key="$3"
local search_cmd="$4"
local id_field="$5"
info "Checking ${service_name} queue for stalled items..."
local queue_json
queue_json=$(curl -sf "${arr_url}/api/v3/queue?page=1&pageSize=200&apikey=${arr_key}&includeMovie=true&includeSeries=true" 2>/dev/null || echo '{"records":[]}')
local matches
matches=$(echo "$queue_json" | python3 -c "
import json, sys
stalled = set('''${STALLED_HASHES}'''.strip().split('\n'))
data = json.load(sys.stdin)
for rec in data.get('records', []):
dl_id = rec.get('downloadId', '').lower()
if dl_id in stalled:
item_id = rec.get('${id_field}', '')
queue_id = rec.get('id', '')
title = rec.get('title', 'Unknown')
print(f'{queue_id}|{item_id}|{title}')
" 2>/dev/null)
if [[ -z "$matches" ]]; then
info "No stalled items found in ${service_name} queue"
return
fi
local match_count
match_count=$(echo "$matches" | grep -c .)
info "Found $match_count stalled item(s) in ${service_name} queue"
# Collect queue IDs and media IDs
local queue_ids=()
local media_ids=()
while IFS='|' read -r queue_id media_id title; do
info " Stalled: $title"
queue_ids+=("$queue_id")
# Avoid duplicate media IDs (e.g., multiple episodes of the same series)
local already_added=false
for existing in "${media_ids[@]:-}"; do
[[ "$existing" == "$media_id" ]] && already_added=true && break
done
$already_added || media_ids+=("$media_id")
done <<< "$matches"
if $DRY_RUN; then
warn "[DRY RUN] Would remove ${#queue_ids[@]} queue item(s) and search ${#media_ids[@]} title(s) in ${service_name}"
return
fi
# Build JSON array of queue IDs
local ids_json
ids_json=$(printf '%s\n' "${queue_ids[@]}" | python3 -c "import sys,json; print(json.dumps([int(x.strip()) for x in sys.stdin if x.strip()]))")
# Remove from queue with blocklist
info "Removing ${#queue_ids[@]} stalled item(s) from ${service_name} queue (blocklisting)..."
local remove_result
remove_result=$(curl -sf -w "%{http_code}" -X DELETE \
"${arr_url}/api/v3/queue/bulk?apikey=${arr_key}" \
-H "Content-Type: application/json" \
-d "{\"ids\": ${ids_json}, \"removeFromClient\": true, \"blocklist\": true, \"skipRedownload\": false}" 2>/dev/null)
local http_code="${remove_result: -3}"
if [[ "$http_code" == "200" ]] || [[ "$http_code" == "202" ]]; then
info "Successfully removed and blocklisted stalled downloads"
else
err "Failed to remove queue items (HTTP $http_code)"
return
fi
# Trigger search for each unique media item
for media_id in "${media_ids[@]}"; do
info "Triggering ${service_name} search for ${id_field}=${media_id}..."
local search_body
if [[ "$id_field" == "movieId" ]]; then
search_body="{\"name\": \"${search_cmd}\", \"movieIds\": [${media_id}]}"
else
search_body="{\"name\": \"${search_cmd}\", \"seriesId\": ${media_id}}"
fi
local search_result
search_result=$(curl -sf -w "%{http_code}" -X POST \
"${arr_url}/api/v3/command?apikey=${arr_key}" \
-H "Content-Type: application/json" \
-d "$search_body" 2>/dev/null)
local search_code="${search_result: -3}"
if [[ "$search_code" == "201" ]] || [[ "$search_code" == "200" ]]; then
info " Search triggered successfully"
else
warn " Search trigger returned HTTP $search_code"
fi
done
}
# --- Run for Radarr and Sonarr ---
process_arr_queue "Radarr" "$RADARR_URL" "$RADARR_API_KEY" "MoviesSearch" "movieId"
process_arr_queue "Sonarr" "$SONARR_URL" "$SONARR_API_KEY" "SeriesSearch" "seriesId"
info "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment