#!/bin/bash # ----------------------------------------------------------------------------------------------- # --------------------------------- Downloaders Reset ------------------------------------------ # ----------------------------------------------------------------------------------------------- # Daily maintenance reset for all download clients. # Clears stuck states, purges old history, and prepares downloaders for a clean daily cycle. # Run before container restarts in daily_sync_maintenance.sh via MEDIA_MANAGEMENT_JOBS. # # Downloaders covered: # slskd — clears stuck/errored searches, dead transfer records, # purges expired failed imports (albums Lidarr rejected) # SABnzbd — clears completed and failed history older than retention period, # removes stalled/paused queue items # qBittorrent — last chance failsafe delete for torrents older than # QBIT_FAILSAFE_MIN_DAYS regardless of ratio # # Safety: # Always --dry-run first before scheduling # slskd transfer cleanup skips any user with InProgress or Queued transfers # qBittorrent only deletes if torrent age exceeds QBIT_FAILSAFE_MIN_DAYS # SABnzbd only deletes history older than DOWNLOADER_RETENTION_DAYS # qBittorrent deleteFiles=false — removes from qBit, leaves files for arrs to manage # # Configuration in Master.conf under Docker Essentials — Downloaders Reset. # Supports --dry-run to preview without making changes. # ----------------------------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../Master.conf" source "$SCRIPT_DIR/../common.sh" parse_args "$@" # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_GEAR Setup ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━ $ICON_GEAR Downloaders Reset ━━━" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi detect_hosts acquire_lock "wait" START_TIME=$(date +%s) # Select host-specific config if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then SLSKD_URL="$HOST1_SLSKD_URL" SLSKD_API_KEY="$HOST1_SLSKD_API_KEY" SLSKD_FAILED_IMPORTS_DIR="$HOST1_SLSKD_FAILED_IMPORTS_DIR" SABNZBD_URL="$HOST1_SABNZBD_URL" SABNZBD_API_KEY="$HOST1_SABNZBD_API_KEY" QBIT_URL="$HOST1_QBIT_URL" QBIT_USERNAME="$HOST1_QBIT_USERNAME" QBIT_PASSWORD="$HOST1_QBIT_PASSWORD" else # HOST2 placeholders — fill in when HOST2 is back online SLSKD_URL="${HOST2_SLSKD_URL:-}" SLSKD_API_KEY="${HOST2_SLSKD_API_KEY:-}" SLSKD_FAILED_IMPORTS_DIR="${HOST2_SLSKD_FAILED_IMPORTS_DIR:-}" SABNZBD_URL="${HOST2_SABNZBD_URL:-}" SABNZBD_API_KEY="${HOST2_SABNZBD_API_KEY:-}" QBIT_URL="${HOST2_QBIT_URL:-}" QBIT_USERNAME="${HOST2_QBIT_USERNAME:-}" QBIT_PASSWORD="${HOST2_QBIT_PASSWORD:-}" fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" CUTOFF=$(( $(date +%s) - (DOWNLOADER_RETENTION_DAYS * 86400) )) TOTAL_PASS=0 TOTAL_FAIL=0 # ----------------------------------------------------------------------------------------------- # ━━━ slskd — Stuck Searches ━━━ # Clears searches in Completed/Errored state left by Soularr crashes # Prevents 409 Conflict error on next Soularr startup # ----------------------------------------------------------------------------------------------- if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]]; 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" | grep -o '"id":"[^"]*","isComplete":true' | \ 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 info "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 info "$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 # NEVER removes transfers that are InProgress or Queued # ----------------------------------------------------------------------------------------------- if [[ -n "$SLSKD_URL" ]] && [[ -n "$SLSKD_API_KEY" ]]; 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 SUCCESS=0; SKIPPED=0; FAIL=0 while IFS= read -r USER; do [[ -z "$USER" ]] && continue ACTIVE=$(echo "$TRANSFERS" | grep -o "\"username\":\"$USER\"[^}]*\"state\":\"[^\"]*\"" | \ grep -c "InProgress\|Queued") if [[ "$ACTIVE" -gt 0 ]]; then info "$ICON_SKIP Skipping $USER — $ACTIVE active/queued transfer(s)" ((SKIPPED++)) continue fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would clear transfers for: $USER" ((SUCCESS++)) continue fi RESULT=$(curl -sf --max-time 10 -o /dev/null -w "%{http_code}" -X DELETE \ "$SLSKD_URL/api/v0/transfers/downloads/$USER" \ -H "X-Api-Key: $SLSKD_API_KEY") if [[ "$RESULT" == "200" || "$RESULT" == "204" ]]; then info "$ICON_TRASH Cleared transfers for: $USER" ((SUCCESS++)) else error "Failed to clear: $USER (HTTP $RESULT)" ((FAIL++)) fi done <<< "$USERNAMES" success "Transfers: $SUCCESS cleared, $SKIPPED skipped (active), $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 these to failed_imports/ and never cleans them up # ----------------------------------------------------------------------------------------------- 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 info "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 # ----------------------------------------------------------------------------------------------- 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 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]*') [[ -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 info "$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 # ----------------------------------------------------------------------------------------------- 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 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]*') [[ -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 info "$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 paused queue items no longer progressing # Active downloading items are never touched # ----------------------------------------------------------------------------------------------- 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 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/"//') [[ "$STATUS" != "Paused" ]] && ((SKIPPED++)) && continue if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — would remove stalled item: $NZO_ID" ((DELETED++)) else curl -sf --max-time 10 \ "$SABNZBD_URL/api?mode=queue&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ >/dev/null info "$ICON_TRASH Removed stalled: $NZO_ID" ((DELETED++)) fi done <<< "$STALLED_IDS" success "Queue: $DELETED removed, $SKIPPED active (skipped)" (( TOTAL_PASS += DELETED )) fi fi fi # ----------------------------------------------------------------------------------------------- # ━━━ qBittorrent — Last Chance Failsafe Cleanup ━━━ # Deletes torrents older than QBIT_FAILSAFE_MIN_DAYS # deleteFiles=false — removes from qBit, leaves files for arrs to manage # ----------------------------------------------------------------------------------------------- if [[ -n "$QBIT_URL" ]] && [[ -n "$QBIT_USERNAME" ]]; then echo "" echo "━━━ 🔍 qBittorrent — Failsafe (older than ${QBIT_FAILSAFE_MIN_DAYS} days) ━━━" [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]] && \ info "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" ((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) 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_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue 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 info "$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 # ----------------------------------------------------------------------------------------------- # ━━━ $ICON_SUMMARY Summary ━━━ # ----------------------------------------------------------------------------------------------- echo "" echo "━━━━━ $ICON_SUMMARY DOWNLOADERS RESET SUMMARY ━━━━━" 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"