Created
March 15, 2026 06:34
-
-
Save dougrathbone/e39a49b4a1a6e090c11f21abb4d930b2 to your computer and use it in GitHub Desktop.
Seerr stalled download fixer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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