#!/bin/bash # ============================================================================================== # downloaders_reset.sh # ============================================================================================== # PURPOSE: 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 maintenance. # # DOWNLOADERS COVERED: # slskd — clears stuck/errored searches, dead transfer records, # purges expired failed imports # 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 # # USAGE: # bash downloaders_reset.sh # live run # bash downloaders_reset.sh --dry-run # preview only, no changes made # # SCHEDULE: Run before downloader restarts in daily_sync_maintenance.sh # Ensures all downloaders start each day in a clean known state # # 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 RETENTION_DAYS # # TO ADD A NEW DOWNLOADER: # Add URL, API key, and any specific variables in Configuration section # Add a new section block following the same pattern as existing downloaders # ============================================================================================== # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ Configuration ━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Retention period in days — applies to ALL cleanup sections: # slskd failed imports, SABnzbd completed history, SABnzbd failed history RETENTION_DAYS=7 # ━━━ slskd ━━━ SLSKD_URL="http://localhost:8980" SLSKD_API_KEY="4bF9kL2mNpQrT7vWxYz1A3dEgHjKoRsU" # Path to Soularr failed imports folder # Created by Soularr when Lidarr rejects a downloaded album SLSKD_FAILED_IMPORTS_DIR="/mnt/user/Temp_Storage/Slskd/completed/failed_imports" # ━━━ SABnzbd ━━━ SABNZBD_URL="http://localhost:8180" SABNZBD_API_KEY="8bfefe41d83b4d50883e32859b55ca9a" # ━━━ qBittorrent ━━━ QBIT_URL="http://localhost:8080" QBIT_USERNAME="admin" QBIT_PASSWORD="changeme" # Last chance failsafe — deletes torrents older than this many days. # qBittorrent's own rules handle normal cleanup (ratio >= 1.25 OR 45 days # inactive). This catches anything missed after an extended period. # QBIT_FAILSAFE_MIN_RATIO=0 disables ratio gate — age is the only condition. QBIT_FAILSAFE_MIN_DAYS=180 QBIT_FAILSAFE_MIN_RATIO=0 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ DRY_RUN=false [[ "$1" == "--dry-run" ]] && DRY_RUN=true $DRY_RUN && echo "🧪 DRY RUN MODE — no changes will be made" echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ slskd — Clear Stuck Searches ━━━ # Clears searches in Completed/Errored state left behind by Soularr crashes. # Prevents 409 Conflict error on next Soularr startup. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 slskd — Stuck Searches" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" SEARCHES=$(curl -s -X GET "$SLSKD_URL/api/v0/searches" \ -H "X-Api-Key: $SLSKD_API_KEY") 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) if [[ -z "$IDS" || "$COUNT" -eq 0 ]]; then echo "✅ No stuck searches found" else echo "📋 Found $COUNT stuck search(es)" if $DRY_RUN; then echo "🧪 Would delete search IDs:" echo "$IDS" else SUCCESS=0 FAIL=0 while IFS= read -r ID; do [[ -z "$ID" ]] && continue RESULT=$(curl -s -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 echo " 🗑️ Cleared search: $ID" ((SUCCESS++)) else echo " ❌ Failed: $ID (HTTP $RESULT)" ((FAIL++)) fi done <<< "$IDS" echo "✅ Searches: $SUCCESS cleared, $FAIL failed" fi fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ slskd — Clear Dead Transfer Records ━━━ # Removes completed/errored/aborted transfer records per user. # Prevents Soularr 404 loop when polling a user whose transfer # record no longer exists after a crash or disconnect. # NEVER removes transfers that are InProgress or Queued. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 slskd — Dead Transfer Records" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" TRANSFERS=$(curl -s -X GET "$SLSKD_URL/api/v0/transfers/downloads" \ -H "X-Api-Key: $SLSKD_API_KEY") USERNAMES=$(echo "$TRANSFERS" | grep -o '"username":"[^"]*"' | \ sed 's/"username":"//;s/"//' | sort -u) if [[ -z "$USERNAMES" ]]; then echo "✅ No transfer records found" else SUCCESS=0 SKIPPED=0 FAIL=0 while IFS= read -r USER; do [[ -z "$USER" ]] && continue USER_STATES=$(echo "$TRANSFERS" | grep -o \ "\"username\":\"$USER\"[^}]*\"state\":\"[^\"]*\"" | \ grep -o '"state":"[^"]*"') ACTIVE=$(echo "$USER_STATES" | grep -c "InProgress\|Queued") if [[ "$ACTIVE" -gt 0 ]]; then echo " ⏳ Skipping $USER — $ACTIVE active/queued transfer(s)" ((SKIPPED++)) continue fi if $DRY_RUN; then echo "🧪 Would clear transfers for: $USER" ((SUCCESS++)) continue fi RESULT=$(curl -s -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 echo " 🗑️ Cleared transfers for: $USER" ((SUCCESS++)) else echo " ❌ Failed to clear: $USER (HTTP $RESULT)" ((FAIL++)) fi done <<< "$USERNAMES" echo "✅ Transfers: $SUCCESS cleared, $SKIPPED skipped (active), $FAIL failed" fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ slskd — Purge Expired Failed Imports ━━━ # Removes albums Soularr downloaded but Lidarr rejected. # Soularr moves these to failed_imports/ and never cleans them up. # Retention controlled by RETENTION_DAYS variable. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 slskd — Failed Imports (older than ${RETENTION_DAYS} days)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [[ ! -d "$SLSKD_FAILED_IMPORTS_DIR" ]]; then echo "⚠️ Directory not found: $SLSKD_FAILED_IMPORTS_DIR" else OLD_IMPORTS=$(find "$SLSKD_FAILED_IMPORTS_DIR" \ -mindepth 1 -maxdepth 1 -mtime +${RETENTION_DAYS}) IMPORT_COUNT=$(echo "$OLD_IMPORTS" | grep -c . 2>/dev/null || echo 0) if [[ -z "$OLD_IMPORTS" || "$IMPORT_COUNT" -eq 0 ]]; then echo "✅ No expired failed imports found" else echo "📋 Found $IMPORT_COUNT expired failed import(s)" if $DRY_RUN; then echo "🧪 Would delete:" echo "$OLD_IMPORTS" else find "$SLSKD_FAILED_IMPORTS_DIR" \ -mindepth 1 -maxdepth 1 -mtime +${RETENTION_DAYS} \ -exec rm -rf {} \; echo "🗑️ Purged $IMPORT_COUNT expired failed import(s)" fi fi fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ SABnzbd — Clear Completed History ━━━ # Removes completed download history older than RETENTION_DAYS. # Keeps history clean without losing recent job records. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 SABnzbd — Completed History (older than ${RETENTION_DAYS} days)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" CUTOFF=$(( $(date +%s) - (RETENTION_DAYS * 86400) )) HISTORY=$(curl -s \ "$SABNZBD_URL/api?mode=history&output=json&limit=1000&apikey=$SABNZBD_API_KEY") COMPLETED_IDS=$(echo "$HISTORY" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$COMPLETED_IDS" ]]; then echo "✅ 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; then echo "🧪 Would delete completed job: $NZO_ID" ((DELETED++)) else curl -s \ "$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ > /dev/null echo " 🗑️ Deleted: $NZO_ID" ((DELETED++)) fi done <<< "$COMPLETED_IDS" echo "✅ Completed: $DELETED deleted, $SKIPPED within retention period" fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ SABnzbd — Clear Failed History ━━━ # Removes failed download history older than RETENTION_DAYS. # Failed jobs older than retention period serve no purpose. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 SABnzbd — Failed History (older than ${RETENTION_DAYS} days)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" FAILED=$(curl -s \ "$SABNZBD_URL/api?mode=history&output=json&limit=1000&failed_only=1&apikey=$SABNZBD_API_KEY") FAILED_IDS=$(echo "$FAILED" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$FAILED_IDS" ]]; then echo "✅ No failed history found" else DELETED=0 SKIPPED=0 while IFS= read -r NZO_ID; do [[ -z "$NZO_ID" ]] && continue JOB_TIME=$(echo "$FAILED" | 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; then echo "🧪 Would delete failed job: $NZO_ID" ((DELETED++)) else curl -s \ "$SABNZBD_URL/api?mode=history&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ > /dev/null echo " 🗑️ Deleted: $NZO_ID" ((DELETED++)) fi done <<< "$FAILED_IDS" echo "✅ Failed: $DELETED deleted, $SKIPPED within retention period" fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ SABnzbd — Remove Stalled Queue Items ━━━ # Removes paused queue items that are no longer progressing. # Active downloading items are never touched. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 SABnzbd — Stalled Queue Items" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" QUEUE=$(curl -s \ "$SABNZBD_URL/api?mode=queue&output=json&apikey=$SABNZBD_API_KEY") STALLED_IDS=$(echo "$QUEUE" | grep -o '"nzo_id":"[^"]*"' | \ sed 's/"nzo_id":"//;s/"//') if [[ -z "$STALLED_IDS" ]]; then echo "✅ 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; then echo "🧪 Would remove stalled item: $NZO_ID" ((DELETED++)) else curl -s \ "$SABNZBD_URL/api?mode=queue&name=delete&value=$NZO_ID&apikey=$SABNZBD_API_KEY" \ > /dev/null echo " 🗑️ Removed: $NZO_ID" ((DELETED++)) fi done <<< "$STALLED_IDS" echo "✅ Queue: $DELETED removed, $SKIPPED active (skipped)" fi echo "" # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━ qBittorrent — Last Chance Failsafe Cleanup ━━━ # Deletes torrents older than QBIT_FAILSAFE_MIN_DAYS. # qBittorrent's own rules handle normal cleanup (ratio >= 1.25 OR 45 days # inactive). This catches anything missed after extended time. # QBIT_FAILSAFE_MIN_RATIO=0 disables ratio gate — age is the only condition. # deleteFiles=false — removes torrent from qBit but leaves files on disk. # Radarr/Sonarr manage the actual files independently. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔍 qBittorrent — Failsafe Cleanup" echo " Age > ${QBIT_FAILSAFE_MIN_DAYS} days" [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]] && \ echo " Ratio >= ${QBIT_FAILSAFE_MIN_RATIO}" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" QBIT_COOKIE=$(curl -s -c - \ "$QBIT_URL/api/v2/auth/login" \ --data "username=$QBIT_USERNAME&password=$QBIT_PASSWORD" | \ grep SID | awk '{print "SID="$NF}') if [[ -z "$QBIT_COOKIE" ]]; then echo "❌ Failed to authenticate with qBittorrent — check URL, username, password" else TORRENTS=$(curl -s \ "$QBIT_URL/api/v2/torrents/info" \ -H "Cookie: $QBIT_COOKIE") HASHES=$(echo "$TORRENTS" | grep -o '"hash":"[^"]*"' | \ sed 's/"hash":"//;s/"//') NOW=$(date +%s) DELETED=0 SKIPPED=0 while IFS= read -r HASH; do [[ -z "$HASH" ]] && continue ADDED=$(echo "$TORRENTS" | grep -A30 "\"hash\":\"$HASH\"" | \ grep -o '"added_on":[0-9]*' | grep -o '[0-9]*' | head -1) RATIO=$(echo "$TORRENTS" | grep -A30 "\"hash\":\"$HASH\"" | \ grep -o '"ratio":[0-9.]*' | grep -o '[0-9.]*' | head -1) NAME=$(echo "$TORRENTS" | grep -A30 "\"hash\":\"$HASH\"" | \ grep -o '"name":"[^"]*"' | sed 's/"name":"//;s/"//' | head -1) [[ -z "$ADDED" ]] && continue # Age in days — skip if under threshold AGE_DAYS=$(( (NOW - ADDED) / 86400 )) [[ "$AGE_DAYS" -lt "$QBIT_FAILSAFE_MIN_DAYS" ]] && ((SKIPPED++)) && continue # Ratio gate — skip if ratio below minimum (disabled when set to 0) if [[ "$QBIT_FAILSAFE_MIN_RATIO" != "0" ]]; then RATIO_OK=$(echo "$RATIO >= $QBIT_FAILSAFE_MIN_RATIO" | bc -l 2>/dev/null) [[ "$RATIO_OK" != "1" ]] && ((SKIPPED++)) && continue fi if $DRY_RUN; then echo "🧪 Would delete: $NAME (${AGE_DAYS} days old, ratio: $RATIO)" ((DELETED++)) else curl -s -X POST \ "$QBIT_URL/api/v2/torrents/delete" \ -H "Cookie: $QBIT_COOKIE" \ --data "hashes=$HASH&deleteFiles=false" echo " 🗑️ Deleted: $NAME (${AGE_DAYS} days old, ratio: $RATIO)" ((DELETED++)) fi done <<< "$HASHES" echo "✅ qBittorrent: $DELETED deleted, $SKIPPED skipped" fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "✅ Downloaders reset complete" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"