#!/bin/bash # ============================================================================================== # ================================= Downloaders Reset ========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Maintenance reset for all download clients on this server. Clears accumulated # state that download clients generate but never clean up themselves — stuck # searches, dead transfers, failed imports, stale queue entries, completed history. # # Called every 30 minutes by critical_sync_maintenance.sh via # CRITICAL_MAINTENANCE_SCRIPTS. Can also be run manually for ad hoc cleanup. # If a downloader is not configured for this host, that section skips cleanly. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # slskd # Stuck searches — clears Completed/Errored searches left by Soularr crashes # prevents 409 Conflict on next Soularr startup # Dead transfers — removes completed/errored/aborted transfer records per user # prevents Soularr 404 loop when polling a user whose transfer is gone # NEVER removes InProgress or Queued transfers # Failed imports — purges albums Soularr downloaded but Lidarr rejected # Soularr moves these to failed_imports/ and never cleans them up # # SABnzbd # Completed history — removes completed download records older than DOWNLOADER_RETENTION_DAYS # Failed history — removes failed download records older than DOWNLOADER_RETENTION_DAYS # Stalled queue — removes Paused or Stuck queue items no longer progressing # active downloading items are never touched # # qBittorrent # Age failsafe — removes torrents older than QBIT_FAILSAFE_MIN_DAYS # deleteFiles=false — removes from qBit, leaves files for arrs to manage # optional ratio requirement via QBIT_FAILSAFE_MIN_RATIO # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Never Interrupt Active Downloads # Each downloader section checks for active state before any removal. slskd # skips users with InProgress or Queued transfers. SABnzbd only removes items # past the retention threshold. qBittorrent applies minimum age and optional # ratio requirements. In-progress work is never touched. # # Graceful Skip on Unavailability # If a downloader's URL is empty or the service is unreachable, that section # skips cleanly with a log message. The script never exits fatally on a single # unreachable downloader — the others still run. # # Host-Aware Configuration # detect_hosts() aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars # to their unprefixed names. Downloaders not configured for this host are absent # from the aliased vars and skip automatically. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Active Transfer Protection # slskd: skips users with InProgress or Queued transfers before any removal. # SABnzbd: age threshold enforced before deletion. # qBittorrent: minimum age plus optional ratio gate before failsafe removal. # # Reachability Check # Each section validates its downloader URL before API calls. Missing or # unreachable downloaders skip without affecting other sections. # # Host Detection # detect_hosts() identifies which server is running the script and aliases # all HOST*_SLSKD_*, HOST*_SABNZBD_*, and HOST*_QBIT_* vars to the correct # host's values. Downloaders not configured on this host skip automatically. # # Lock Acquisition # acquire_lock "wait" — waits for previous run to finish since this runs every # 30 minutes and prior execution may still be completing. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST*_SLSKD_URL / HOST*_SLSKD_API_KEY / HOST*_SLSKD_FAILED_IMPORTS_DIR # slskd connection and failed imports path. Aliased by detect_hosts() # # HOST*_SABNZBD_URL / HOST*_SABNZBD_API_KEY # SABnzbd connection details. Aliased by detect_hosts() # # HOST*_QBIT_URL / HOST*_QBIT_USERNAME / HOST*_QBIT_PASSWORD # qBittorrent connection details. Aliased by detect_hosts() # # master.conf # # DOWNLOADER_RETENTION_DAYS # Days before SABnzbd history entries (completed or failed) are removed # # QBIT_FAILSAFE_MIN_DAYS # Minimum torrent age in days before failsafe removal is considered # # QBIT_FAILSAFE_MIN_RATIO # Minimum seeding ratio required alongside age gate (0 = age only) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # downloaders_reset.sh # Run maintenance reset for all configured download clients # # downloaders_reset.sh --dry-run # Preview what would be removed without making any changes # # downloaders_reset.sh --status # Show configured downloaders, current queue depths, and retention settings # # downloaders_reset.sh --log # Verbose per-client per-item output # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi # Lock first — wait mode since this runs every 30min and previous may still be finishing acquire_lock "wait" # detect_hosts() sets MY_ID and aliases all HOST*_SLSKD_*, HOST*_SABNZBD_*, HOST*_QBIT_* vars detect_hosts START_TIME=$(date +%s) CUTOFF=$(( $(date +%s) - (DOWNLOADER_RETENTION_DAYS * 86400) )) TOTAL_PASS=0 TOTAL_FAIL=0 log "$ICON_GEAR Config: retention=${DOWNLOADER_RETENTION_DAYS}d qbit-age=${QBIT_FAILSAFE_MIN_DAYS}d qbit-ratio=${QBIT_FAILSAFE_MIN_RATIO}" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR slskd: ${SLSKD_URL:-not configured}" echo "$ICON_GEAR SABnzbd: ${SABNZBD_URL:-not configured}" echo "$ICON_GEAR qBittorrent: ${QBIT_URL:-not configured}" echo "$ICON_TIME Retention: ${DOWNLOADER_RETENTION_DAYS} days" echo "$ICON_GEAR qBit age: ${QBIT_FAILSAFE_MIN_DAYS} days" echo "$ICON_GEAR qBit ratio: ${QBIT_FAILSAFE_MIN_RATIO} (0=age only)" echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" # Log which downloaders are active on this host if [[ -z "$SLSKD_URL" ]] && [[ -z "$SABNZBD_URL" ]] && [[ -z "$QBIT_URL" ]]; then warn "No downloaders configured for $MY_ID — nothing to reset" exit 0 fi [[ -n "$SLSKD_URL" ]] && log "slskd active on $MY_ID" [[ -n "$SABNZBD_URL" ]] && log "SABnzbd active on $MY_ID" [[ -n "$QBIT_URL" ]] && log "qBittorrent active on $MY_ID" # ============================================================================================== # ━━━ slskd — Connection Check ━━━ # ============================================================================================== # slskd's internal watchdog doesn't always recover from disconnection. Check before # running API-dependent sections; attempt reconnect if down. SLSKD_CONNECTED=false if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]]; then echo "" echo "━━━ $ICON_SYNC slskd — Connection Check ━━━" _slskd_is_connected() { local state state=$(curl -sf --max-time 10 \ -H "X-Api-Key: $SLSKD_API_KEY" \ "$SLSKD_URL/api/v0/application" 2>/dev/null | \ jq -r '.server.isConnected // false' 2>/dev/null) [[ "$state" == "true" ]] } if _slskd_is_connected; then log "slskd connected to Soulseek ✅" SLSKD_CONNECTED=true else warn "slskd disconnected — triggering reconnect" curl -sf --max-time 10 -X PUT \ -H "X-Api-Key: $SLSKD_API_KEY" \ -H "Content-Type: application/json" \ "$SLSKD_URL/api/v0/server" \ -d '{"address":"server.slsknet.org","port":2242}' \ >/dev/null 2>&1 _ELAPSED=0 while [[ "$_ELAPSED" -lt 60 ]]; do sleep 10 _ELAPSED=$(( _ELAPSED + 10 )) if _slskd_is_connected; then log "slskd reconnected after ${_ELAPSED}s ✅" SLSKD_CONNECTED=true break fi log " waiting... (${_ELAPSED}s / 60s)" done [[ "$SLSKD_CONNECTED" != true ]] && \ warn "slskd still disconnected after 60s — skipping API-dependent sections" fi fi # ============================================================================================== # ━━━ slskd — Stuck Searches ━━━ # ============================================================================================== # Clears searches in Completed/Errored state left by Soularr crashes. # Prevents 409 Conflict error on next Soularr startup when it tries to # create a search with the same ID that already exists in a terminal state. if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then echo "" echo "━━━ 🔍 slskd — Stuck Searches ━━━" SEARCHES=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/searches" \ -H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null) if [[ -z "$SEARCHES" ]]; then warn "slskd not reachable — skipping searches" else IDS=$(echo "$SEARCHES" | tr '{' '\n' | \ grep '"isComplete":true' | grep '"searchText":' | \ grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//') COUNT=$(echo "$IDS" | grep -c . 2>/dev/null || echo 0) COUNT="${COUNT//[^0-9]/}"; COUNT="${COUNT:-0}" if [[ "$COUNT" -eq 0 ]]; then success "No stuck searches found ✅" else log "Found $COUNT stuck search(es)" SUCCESS=0; FAIL=0 while IFS= read -r ID; do [[ -z "$ID" ]] && continue if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would delete search: $ID" ((SUCCESS++)) continue fi RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \ "$SLSKD_URL/api/v0/searches/$ID" \ -H "X-Api-Key: $SLSKD_API_KEY") if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then log "$ICON_TRASH Cleared search: $ID" ((SUCCESS++)) else error "Failed: $ID (HTTP $RESULT)" ((FAIL++)) fi done <<< "$IDS" success "Searches: $SUCCESS cleared, $FAIL failed" (( TOTAL_FAIL += FAIL )) (( TOTAL_PASS += SUCCESS )) fi fi fi # ============================================================================================== # ━━━ slskd — Dead Transfer Records ━━━ # ============================================================================================== # Removes completed/errored/aborted transfer records per user. # Prevents Soularr 404 loop when polling a user whose transfer no longer exists. # Safety: NEVER removes transfers that are InProgress or Queued — active downloads protected. if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]] && [[ "$SLSKD_CONNECTED" == true ]]; then echo "" echo "━━━ 🔍 slskd — Dead Transfer Records ━━━" TRANSFERS=$(curl -sf --max-time 10 -X GET "$SLSKD_URL/api/v0/transfers/downloads" \ -H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null) if [[ -z "$TRANSFERS" ]]; then warn "slskd not reachable — skipping transfers" else USERNAMES=$(echo "$TRANSFERS" | grep -o '"username":"[^"]*"' | \ sed 's/"username":"//;s/"//' | sort -u) if [[ -z "$USERNAMES" ]]; then success "No transfer records found ✅" else USER_COUNT=$(echo "$USERNAMES" | grep -c . 2>/dev/null || echo 0) log "Found $USER_COUNT user(s) with transfer records" SUCCESS=0; SKIPPED=0; FAIL=0 while IFS= read -r USER; do [[ -z "$USER" ]] && continue USER_DATA=$(curl -sf --max-time 10 \ "$SLSKD_URL/api/v0/transfers/downloads/$USER" \ -H "X-Api-Key: $SLSKD_API_KEY" 2>/dev/null) # Skip users with any active or queued transfers — never interrupt downloads ACTIVE=$(echo "$USER_DATA" | grep -c '"state":"InProgress"\|"state":"Queued"') if [[ "${ACTIVE:-0}" -gt 0 ]]; then log "$ICON_SKIP Skipping $USER — has active/queued transfer(s)" ((SKIPPED++)) continue fi # Extract IDs of terminal-state file transfers # Split at { so each file object lands on its own line, then grep for state FILE_IDS=$(echo "$USER_DATA" | tr '{' '\n' | \ grep '"state":"Completed"\|"state":"Errored"\|"state":"Aborted"\|"state":"Cancelled"' | \ grep -o '"id":"[^"]*"' | sed 's/"id":"//;s/"//') if [[ -z "$FILE_IDS" ]]; then log "$ICON_SKIP Skipping $USER — no terminal-state transfers" ((SKIPPED++)) continue fi if [[ "$DRY_RUN" == true ]]; then F_COUNT=$(echo "$FILE_IDS" | grep -c .) warn "DRY RUN — would clear $F_COUNT transfer(s) for: $USER" ((SUCCESS++)) continue fi F_SUCCESS=0; F_FAIL=0 while IFS= read -r FILE_ID; do [[ -z "$FILE_ID" ]] && continue RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \ "$SLSKD_URL/api/v0/transfers/downloads/$USER/$FILE_ID" \ -H "X-Api-Key: $SLSKD_API_KEY") if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then ((F_SUCCESS++)) else ((F_FAIL++)) fi done <<< "$FILE_IDS" log "$ICON_TRASH Cleared $F_SUCCESS transfer(s) for: $USER ($F_FAIL failed)" ((SUCCESS += F_SUCCESS)) ((FAIL += F_FAIL)) done <<< "$USERNAMES" success "Transfers: $SUCCESS cleared, $SKIPPED skipped (active/empty), $FAIL failed" (( TOTAL_FAIL += FAIL )) (( TOTAL_PASS += SUCCESS )) fi fi fi # ============================================================================================== # ━━━ slskd — Purge Expired Failed Imports ━━━ # ============================================================================================== # Removes albums Soularr downloaded but Lidarr rejected. # Soularr moves rejected albums to failed_imports/ and never cleans them up. # Purges directories older than DOWNLOADER_RETENTION_DAYS to prevent unbounded growth. if [[ -n "$SLSKD_FAILED_IMPORTS_DIR" ]]; then echo "" echo "━━━ 🔍 slskd — Failed Imports (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━" if [[ ! -d "$SLSKD_FAILED_IMPORTS_DIR" ]]; then warn "Directory not found: $SLSKD_FAILED_IMPORTS_DIR — skipping" else OLD_IMPORTS=$(find "$SLSKD_FAILED_IMPORTS_DIR" \ -mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}") IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || echo 0) IMPORT_COUNT="${IMPORT_COUNT//[^0-9]/}"; IMPORT_COUNT="${IMPORT_COUNT:-0}" if [[ "$IMPORT_COUNT" -eq 0 ]]; then success "No expired failed imports found ✅" else log "Found $IMPORT_COUNT expired failed import(s)" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would delete:" echo "$OLD_IMPORTS" else find "$SLSKD_FAILED_IMPORTS_DIR" \ -mindepth 1 -maxdepth 1 -mtime +"${DOWNLOADER_RETENTION_DAYS}" \ -exec rm -rf {} \; success "$ICON_TRASH Purged $IMPORT_COUNT expired failed import(s)" (( TOTAL_PASS += IMPORT_COUNT )) fi fi fi fi # ============================================================================================== # ━━━ SABnzbd — Clear Completed History ━━━ # ============================================================================================== # Removes completed download history older than DOWNLOADER_RETENTION_DAYS. # Keeps recent history for reference — only purges what's past the retention window. if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then echo "" echo "━━━ 🔍 SABnzbd — Completed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━" HISTORY=$(curl -sf --max-time 15 \ "$SABNZBD_URL/api?mode=history&output=json&limit=1000&apikey=$SABNZBD_API_KEY" 2>/dev/null) if [[ -z "$HISTORY" ]]; then warn "SABnzbd not reachable — skipping completed history" else COMPLETED_IDS=$(echo "$HISTORY" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$COMPLETED_IDS" ]]; then success "No completed history found ✅" else HIST_TOTAL=$(echo "$COMPLETED_IDS" | grep -c . 2>/dev/null || echo 0) log "Found $HIST_TOTAL completed history entries" DELETED=0; SKIPPED=0 while IFS= read -r NZO_ID; do [[ -z "$NZO_ID" ]] && continue JOB_TIME=$(echo "$HISTORY" | grep -A5 "$NZO_ID" | \ grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1) [[ -z "$JOB_TIME" ]] && continue [[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would delete completed job: $NZO_ID" ((DELETED++)) else curl -sf --max-time 10 \ "$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ >/dev/null log "$ICON_TRASH Deleted: $NZO_ID" ((DELETED++)) fi done <<< "$COMPLETED_IDS" success "Completed: $DELETED deleted, $SKIPPED within retention" (( TOTAL_PASS += DELETED )) fi fi fi # ============================================================================================== # ━━━ SABnzbd — Clear Failed History ━━━ # ============================================================================================== # Removes failed download history older than DOWNLOADER_RETENTION_DAYS. # Failed history is kept briefly for diagnosis but purged after the retention window. if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then echo "" echo "━━━ 🔍 SABnzbd — Failed History (older than ${DOWNLOADER_RETENTION_DAYS} days) ━━━" FAILED_HIST=$(curl -sf --max-time 15 \ "$SABNZBD_URL/api?mode=history&output=json&limit=1000&failed_only=1&apikey=$SABNZBD_API_KEY" 2>/dev/null) if [[ -z "$FAILED_HIST" ]]; then warn "SABnzbd not reachable — skipping failed history" else FAILED_IDS=$(echo "$FAILED_HIST" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$FAILED_IDS" ]]; then success "No failed history found ✅" else FAILED_TOTAL=$(echo "$FAILED_IDS" | grep -c . 2>/dev/null || echo 0) log "Found $FAILED_TOTAL failed history entries" DELETED=0; SKIPPED=0 while IFS= read -r NZO_ID; do [[ -z "$NZO_ID" ]] && continue JOB_TIME=$(echo "$FAILED_HIST" | grep -A5 "$NZO_ID" | \ grep -o '"completed":[0-9]*' | grep -o '[0-9]*' | head -1) [[ -z "$JOB_TIME" ]] && continue [[ "$JOB_TIME" -gt "$CUTOFF" ]] && ((SKIPPED++)) && continue if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would delete failed job: $NZO_ID" ((DELETED++)) else curl -sf --max-time 10 \ "$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ >/dev/null log "$ICON_TRASH Deleted: $NZO_ID" ((DELETED++)) fi done <<< "$FAILED_IDS" success "Failed: $DELETED deleted, $SKIPPED within retention" (( TOTAL_PASS += DELETED )) fi fi fi # ============================================================================================== # ━━━ SABnzbd — Remove Stalled Queue Items ━━━ # ============================================================================================== # Removes queue items in Paused or Stuck state that are no longer progressing. # Active downloading items (Downloading, Grabbing) are never touched. # Paused items may be intentional pauses — but in an automated environment # a Paused item sitting in the queue indefinitely is effectively stalled. if [[ -n "$SABNZBD_URL" ]] && [[ -n "$SABNZBD_API_KEY" ]]; then echo "" echo "━━━ 🔍 SABnzbd — Stalled Queue Items ━━━" QUEUE=$(curl -sf --max-time 10 \ "$SABNZBD_URL/api?mode=queue&output=json&apikey=$SABNZBD_API_KEY" 2>/dev/null) if [[ -z "$QUEUE" ]]; then warn "SABnzbd not reachable — skipping queue" else STALLED_IDS=$(echo "$QUEUE" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$STALLED_IDS" ]]; then success "No stalled queue items found ✅" else QUEUE_TOTAL=$(echo "$STALLED_IDS" | grep -c . 2>/dev/null || echo 0) log "Found $QUEUE_TOTAL queue item(s) — checking status" DELETED=0; SKIPPED=0 while IFS= read -r NZO_ID; do [[ -z "$NZO_ID" ]] && continue STATUS=$(echo "$QUEUE" | grep -A10 "$NZO_ID" | \ grep -o '"status":"[^"]*"' | sed 's/"status":"//;s/"//') # Only remove Paused or Stuck items — Downloading/Grabbing are active if [[ "$STATUS" != "Paused" ]] && [[ "$STATUS" != "Stuck" ]]; then ((SKIPPED++)) continue fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would remove stalled item: $NZO_ID ($STATUS)" ((DELETED++)) else curl -sf --max-time 10 \ "$SABNZBD_URL/api?mode=queue&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ >/dev/null log "$ICON_TRASH Removed stalled ($STATUS): $NZO_ID" ((DELETED++)) fi done <<< "$STALLED_IDS" success "Queue: $DELETED removed, $SKIPPED active (skipped)" (( TOTAL_PASS += DELETED )) fi fi fi # ============================================================================================== # ━━━ qBittorrent — Age Failsafe Cleanup ━━━ # ============================================================================================== # Last-chance cleanup for torrents that have been sitting in qBit past their useful life. # deleteFiles=false — removes the torrent record from qBit but leaves files on disk. # Radarr/Sonarr manage actual files independently — this only cleans up the qBit entry. # # Safety checks before deletion: # Age must exceed QBIT_FAILSAFE_MIN_DAYS # Ratio must meet QBIT_FAILSAFE_MIN_RATIO (0 = age only, no ratio requirement) if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then echo "" echo "━━━ 🔍 qBittorrent — Failsafe (older than ${QBIT_FAILSAFE_MIN_DAYS} days) ━━━" [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]] && \ log "Ratio requirement: >= ${QBIT_FAILSAFE_MIN_RATIO}" QBIT_COOKIE=$(curl -sf --max-time 10 -c - \ "$QBIT_URL/api/v2/auth/login" \ --data "username=$QBIT_USERNAME&password=$QBIT_PASSWORD" 2>/dev/null | \ grep SID | awk '{print "SID="$NF}') if [[ -z "$QBIT_COOKIE" ]]; then error "Failed to authenticate with qBittorrent — check QBIT_USERNAME/PASSWORD" notify "qBittorrent auth failed on $(hostname) — check credentials in host*.conf" "Downloaders Reset" "warning" ((TOTAL_FAIL++)) else TORRENTS=$(curl -sf --max-time 15 \ "$QBIT_URL/api/v2/torrents/info" \ -H "Cookie: $QBIT_COOKIE" 2>/dev/null) NOW=$(date +%s) TORRENT_TOTAL=$(echo "$TORRENTS" | tr '}' '\n' | grep -c '"hash"' 2>/dev/null || echo 0) log "Found $TORRENT_TOTAL torrent(s) — applying age/ratio filter" DELETED=0; SKIPPED=0 while read -r TORRENT; do [[ -z "$TORRENT" ]] && continue HASH=$(echo "$TORRENT" | grep -o '"hash":"[^"]*"' | sed 's/"hash":"//;s/"//') NAME=$(echo "$TORRENT" | grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//') ADDED=$(echo "$TORRENT" | grep -o '"added_on":[0-9]*' | grep -o '[0-9]*') RATIO=$(echo "$TORRENT" | grep -o '"ratio":[0-9.]*' | grep -o '[0-9.]*') [[ -z "$HASH" || -z "$ADDED" ]] && continue AGE_DAYS=$(( (NOW - ADDED) / 86400 )) # Age check — must be old enough [[ "$AGE_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue # Ratio check — if configured if [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]]; then RATIO_INT="${RATIO%.*}" MIN_RATIO_INT="${QBIT_FAILSAFE_MIN_RATIO%.*}" [[ "$RATIO_INT" -lt "$MIN_RATIO_INT" ]] && ((SKIPPED++)) && continue fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would delete: $NAME (${AGE_DAYS}d old, ratio: $RATIO)" ((DELETED++)) else curl -sf --max-time 10 -X POST \ "$QBIT_URL/api/v2/torrents/delete" \ -H "Cookie: $QBIT_COOKIE" \ --data "hashes=$HASH&deleteFiles=false" >/dev/null log "$ICON_TRASH Deleted: $NAME (${AGE_DAYS}d old, ratio: $RATIO)" ((DELETED++)) fi done < <(echo "$TORRENTS" | tr '}' '\n') success "qBittorrent: $DELETED deleted, $SKIPPED skipped (under threshold)" (( TOTAL_PASS += DELETED )) fi fi # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY DOWNLOADERS RESET SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_TIME Duration: $(format_duration $(( $(date +%s) - START_TIME )))" echo "$ICON_SUCCESS Actions: $TOTAL_PASS" echo "$ICON_ERROR Failures: $TOTAL_FAIL" echo "" if [[ "$DRY_RUN" == true ]]; then echo "$ICON_WARN Status: DRY RUN — no changes made" elif [[ "$TOTAL_FAIL" -gt 0 ]]; then echo "$ICON_ERROR Status: $TOTAL_FAIL failure(s) — check logs" notify "Downloaders reset completed with failures on $(hostname)" "Downloaders Reset" "warning" exit 1 else echo "$ICON_DONE Status: $ICON_SUCCESS DONE" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"