recovarr/recovarr.sh

871 lines
37 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# recovarr.sh - Recover a corrupted media file via Sonarr/Radarr
# Relative Path: ./scripts/recovarr.sh
#
# Purpose and usage:
# Given a file path to a corrupted video, this script:
# 1. Identifies the media in Sonarr (TV) or Radarr (Movies) via the parse API
# 2. Checks the download queue for a pending import of this item
# 3. Checks download history + qBittorrent to see if the original torrent is still seeding
# 4. If original is available: deletes the corrupted file record and triggers an import scan
# 5. If not available: deletes the file record and triggers an automatic search
#
# Usage:
# ./recovarr.sh <file_path> [options]
# ./recovarr.sh --batch <file_list.txt> [options]
#
# Options:
# --dry-run Show what would happen without making changes
# --verbose Show detailed API responses
# --search-only Skip availability check, go straight to triggering a search
# --sonarr Force Sonarr (override path-based detection)
# --radarr Force Radarr (override path-based detection)
#
# Config file: ~/.config/media-postprocessor/api-keys.conf
# SONARR_URL=http://your-sonarr-host:8989
# SONARR_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# RADARR_URL=http://your-radarr-host:7878
# RADARR_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# QBIT_USER=admin
# QBIT_PASS=adminadmin
#
# Author: CircuitForge
# Created: 2026-03-26
#
# Requirements:
# - curl: API calls
# - jq: JSON parsing
#
# License: GPL-3.0
set -euo pipefail
# ---------------------------------------------------------------------------
# Colors / output
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m'
print_status() {
local level="$1"; shift
case "$level" in
info) echo -e "${BLUE}[INFO]${NC} $*" >&2 ;;
success) echo -e "${GREEN}[OK]${NC} $*" >&2 ;;
warning) echo -e "${YELLOW}[WARN]${NC} $*" >&2 ;;
error) echo -e "${RED}[ERR]${NC} $*" >&2 ;;
debug) [[ "${VERBOSE:-false}" == "true" ]] && echo -e "${PURPLE}[DBG]${NC} $*" >&2 ;;
step) echo -e "${CYAN}[-->]${NC} $*" >&2 ;;
esac
}
command_exists() { command -v "$1" &>/dev/null; }
# ---------------------------------------------------------------------------
# Defaults / config
# ---------------------------------------------------------------------------
CONFIG_FILE="${ARR_RECOVER_CONFIG:-${HOME}/.config/media-postprocessor/api-keys.conf}"
SONARR_URL="${SONARR_URL:-}"
RADARR_URL="${RADARR_URL:-}"
SONARR_API_KEY=""
RADARR_API_KEY=""
QBIT_INSTANCES=()
QBIT_USER="${QBIT_USER:-admin}"
QBIT_PASS="${QBIT_PASS:-adminadmin}"
DRY_RUN=false
VERBOSE=false
SEARCH_ONLY=false
FORCE_TYPE=""
BATCH_MODE=false
BATCH_FILE=""
# Load config if it exists
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
fi
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
usage() {
echo "Usage: $0 <file_path> [--dry-run] [--verbose] [--search-only] [--sonarr|--radarr]"
echo " $0 --batch <file_list.txt> [options]"
echo ""
echo " --dry-run Show what would happen without making changes"
echo " --verbose Show detailed API responses"
echo " --search-only Skip availability check, trigger search immediately"
echo " --sonarr Force Sonarr (override path detection)"
echo " --radarr Force Radarr (override path detection)"
echo " --batch FILE Process multiple paths from a text file (one per line)"
echo ""
echo " Config file: $CONFIG_FILE"
exit 1
}
UNMONITOR_EPISODE_ID=""
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true ;;
--verbose) VERBOSE=true ;;
--search-only) SEARCH_ONLY=true ;;
--sonarr) FORCE_TYPE="sonarr" ;;
--radarr) FORCE_TYPE="radarr" ;;
--batch) BATCH_MODE=true; BATCH_FILE="$2"; shift ;;
--unmonitor-episode) UNMONITOR_EPISODE_ID="$2"; shift ;;
-h|--help) usage ;;
*) POSITIONAL+=("$1") ;;
esac
shift
done
# ---------------------------------------------------------------------------
# Dependency checks
# ---------------------------------------------------------------------------
for cmd in curl jq; do
if ! command_exists "$cmd"; then
print_status error "Required command '$cmd' not found — install it first"
exit 2
fi
done
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
arr_get() {
local base_url="$1"
local api_key="$2"
local endpoint="$3"
local url="${base_url}/api/v3/${endpoint}"
print_status debug "GET $url"
curl -sf --max-time 15 \
-H "X-Api-Key: $api_key" \
-H "Accept: application/json" \
"$url"
}
arr_post() {
local base_url="$1"
local api_key="$2"
local endpoint="$3"
local body="$4"
local url="${base_url}/api/v3/${endpoint}"
print_status debug "POST $url body=$body"
if [[ "$DRY_RUN" == "true" ]]; then
print_status warning "[DRY-RUN] Would POST $url with: $body"
echo '{"id":0}'
return 0
fi
curl -sf --max-time 15 \
-X POST \
-H "X-Api-Key: $api_key" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$body" \
"$url"
}
# Set monitored state for a single Sonarr episode
remonitor_episode() {
local base_url="$1" api_key="$2" episode_id="$3" monitored="$4"
print_status debug "Setting episode $episode_id monitored=$monitored"
if [[ "$DRY_RUN" == "true" ]]; then
print_status warning "[DRY-RUN] Would set episode $episode_id monitored=$monitored"
return 0
fi
# Sonarr v4: episode/monitor is PUT, not POST
curl -sf --max-time 15 -X PUT \
-H "X-Api-Key: $api_key" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "{\"episodeIds\": [$episode_id], \"monitored\": $monitored}" \
"${base_url}/api/v3/episode/monitor" >/dev/null
}
arr_delete() {
local base_url="$1"
local api_key="$2"
local endpoint="$3"
local url="${base_url}/api/v3/${endpoint}"
print_status debug "DELETE $url"
if [[ "$DRY_RUN" == "true" ]]; then
print_status warning "[DRY-RUN] Would DELETE $url"
return 0
fi
curl -sf --max-time 15 \
-X DELETE \
-H "X-Api-Key: $api_key" \
"$url"
}
# ---------------------------------------------------------------------------
# Scored release selection
# ---------------------------------------------------------------------------
# Stage 1: episode/movie-specific search.
# Stage 2 (Sonarr only): season pack search — triggered when stage 1 finds nothing.
#
# Scoring tiers (lower = better):
# 1: x265 + >=10 seeds -> sort size asc (smallest wins)
# 2: x265 + 4-9 seeds -> sort seeds desc
# 3: non-x265 + >=10 seeds (non-remux) -> sort size asc
# 4: non-x265 + 4-9 seeds (non-remux)
# 5: <=3 seeds or remux -> last resort
# Usenet (null seeders) is treated as tier 3 equivalent (reliable, size-sorted).
# Returns 0 on success, 1 on failure — caller should fall back to EpisodeSearch.
# Shared scoring logic: reads a JSON array from stdin, outputs best candidate as JSON.
_score_releases() {
jq -r '
map(
. as $r |
($r.title | test("x265|x\\.265|HEVC|H\\.265|h265|h\\.265"; "i")) as $x265 |
($r.seeders // 999) as $seeds |
($r.title | test("REMUX"; "i")) as $remux |
(if $seeds >= 10 and $x265 then 1
elif $seeds >= 4 and $x265 then 2
elif $seeds >= 10 and ($x265|not) and ($remux|not) then 3
elif $seeds >= 4 and ($x265|not) and ($remux|not) then 4
else 5
end) as $tier |
$r + {_tier: $tier, _seeds: $seeds, _x265: $x265}
) |
sort_by([
._tier,
(if ._tier == 1 or ._tier == 3
then (.size // 99999999999999)
else (._seeds * -1)
end)
]) |
first |
{guid, indexerId, title, seeders, size: (.size // 0), _tier, _x265}
'
}
# Set to "true" by pick_best_release when a season pack was selected.
# Caller uses this to delete all season episode files before the grab.
PICKED_SEASON_PACK=false
pick_best_release() {
local arr_url="$1" arr_key="$2" arr_type="$3" media_id="$4"
local series_id="${5:-}" season_number="${6:-}"
# ---- Stage 1: episode / movie search ----
print_status step "Searching indexers for best available release..."
print_status info "(Live indexer query — may take up to 60s)"
local releases_json
if [[ "$arr_type" == "sonarr" ]]; then
releases_json=$(curl -sf --max-time 90 \
-H "X-Api-Key: $arr_key" -H "Accept: application/json" \
"${arr_url}/api/v3/release?episodeId=${media_id}" 2>/dev/null || echo '[]')
else
releases_json=$(curl -sf --max-time 90 \
-H "X-Api-Key: $arr_key" -H "Accept: application/json" \
"${arr_url}/api/v3/release?movieId=${media_id}" 2>/dev/null || echo '[]')
fi
local total eligible eligible_count rejected_count
total=$(echo "$releases_json" | jq 'length')
eligible=$(echo "$releases_json" | jq '[.[] | select(.rejected != true)]')
eligible_count=$(echo "$eligible" | jq 'length')
rejected_count=$(( total - eligible_count ))
print_status info "Episode search: $total result(s), $eligible_count eligible, $rejected_count rejected by quality profile"
# ---- Stage 2: season pack fallback (Sonarr only) ----
local is_season_pack=false
if [[ "${eligible_count:-0}" -eq 0 ]] && \
[[ "$arr_type" == "sonarr" ]] && \
[[ -n "$series_id" ]] && [[ -n "$season_number" ]]; then
local season_label
season_label=$(printf 'S%02d' "$season_number")
print_status info "No individual episode releases — searching for season pack ($season_label)..."
local season_json season_eligible season_count
season_json=$(curl -sf --max-time 90 \
-H "X-Api-Key: $arr_key" -H "Accept: application/json" \
"${arr_url}/api/v3/release?seriesId=${series_id}&seasonNumber=${season_number}" \
2>/dev/null || echo '[]')
season_eligible=$(echo "$season_json" | jq '[.[] | select(.rejected != true)]')
season_count=$(echo "$season_eligible" | jq 'length')
local season_rejected=$(( $(echo "$season_json" | jq 'length') - season_count ))
print_status info "Season pack search: $(echo "$season_json" | jq 'length') result(s), $season_count eligible, $season_rejected rejected"
if [[ "$season_count" -gt 0 ]]; then
eligible="$season_eligible"
eligible_count="$season_count"
is_season_pack=true
PICKED_SEASON_PACK=true
fi
fi
if [[ "${eligible_count:-0}" -eq 0 ]]; then
print_status warning "No eligible releases found (episode or season pack) — falling back to automatic search"
return 1
fi
local best
best=$(echo "$eligible" | _score_releases)
if [[ -z "$best" ]] || [[ "$best" == "null" ]]; then
print_status warning "Release scoring produced no result"
return 1
fi
local title seeds size_gb tier
title=$(echo "$best" | jq -r '.title')
seeds=$(echo "$best" | jq -r 'if .seeders == 999 then "Usenet" else (.seeders // "?") | tostring end')
size_gb=$(echo "$best" | jq -r '(.size / 1073741824 * 100 | round) / 100 | tostring + " GB"')
tier=$(echo "$best" | jq -r '._tier')
local tier_label
case "$tier" in
1) tier_label="x265 + >=10 seeds (size-optimised)" ;;
2) tier_label="x265 + 4-9 seeds" ;;
3) tier_label=">=10 seeds, size-optimised" ;;
4) tier_label="4-9 seeds" ;;
5) tier_label="fallback (low seeds / remux)" ;;
esac
if [[ "$is_season_pack" == "true" ]]; then
print_status warning "Season pack selected — all episodes in this season will download"
print_status warning "Other corrupted episodes in the same season are covered by this grab"
fi
print_status success "Selected:"
print_status info " $title"
print_status info " Seeds: $seeds | Size: $size_gb | Tier: $tier_label"
if [[ "$DRY_RUN" == "true" ]]; then
print_status warning "[DRY-RUN] Would grab the above release"
return 0
fi
local guid indexer_id
guid=$(echo "$best" | jq -r '.guid')
indexer_id=$(echo "$best" | jq -r '.indexerId')
print_status step "Grabbing selected release..."
local grab_resp
grab_resp=$(curl -sf --max-time 30 \
-X POST \
-H "X-Api-Key: $arr_key" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "{\"guid\": \"$guid\", \"indexerId\": $indexer_id}" \
"${arr_url}/api/v3/release" 2>/dev/null || echo '{}')
if echo "$grab_resp" | jq -e '.rejected == true' &>/dev/null; then
local reasons
reasons=$(echo "$grab_resp" | jq -r '[.rejections[]?.reason // empty] | join(", ")')
print_status warning "Grab rejected: ${reasons:-unknown reason}"
return 1
fi
print_status success "Release grabbed — download queued in ${arr_type^}"
return 0
}
# Log into a qBittorrent instance.
# Returns cookie jar path, "bypass" if auth is not required, or empty on failure.
qbit_login() {
local base_url="$1"
# Try unauthenticated first — works when local bypass is enabled
local bypass_test
bypass_test=$(curl -sf --max-time 10 \
"${base_url}/api/v2/app/version" 2>/dev/null || echo "")
if [[ -n "$bypass_test" ]]; then
print_status debug " qBit auth bypass active at $base_url"
echo "bypass"
return 0
fi
# Fall back to username/password login
local jar
jar=$(mktemp /tmp/qbit_cookie.XXXXXX)
local result
result=$(curl -sf --max-time 10 \
-c "$jar" \
--data-urlencode "username=$QBIT_USER" \
--data-urlencode "password=$QBIT_PASS" \
"${base_url}/api/v2/auth/login" 2>/dev/null || echo "Fails.")
if [[ "$result" == "Ok." ]]; then
echo "$jar"
else
rm -f "$jar"
echo ""
fi
}
# Check if a torrent hash exists in a qBit instance; returns JSON or empty.
# cookie_jar may be a file path or the string "bypass" (no auth needed).
qbit_check_hash() {
local base_url="$1"
local cookie_jar="$2"
local hash="$3"
if [[ "$cookie_jar" == "bypass" ]]; then
curl -sf --max-time 10 \
"${base_url}/api/v2/torrents/info?hashes=${hash}" 2>/dev/null || echo "[]"
else
curl -sf --max-time 10 \
-b "$cookie_jar" \
"${base_url}/api/v2/torrents/info?hashes=${hash}" 2>/dev/null || echo "[]"
fi
}
# ---------------------------------------------------------------------------
# Core recovery logic for a single file
# ---------------------------------------------------------------------------
recover_file() {
local filepath="$1"
echo "" >&2
print_status step "============================================================"
print_status step "File: $(basename "$filepath")"
print_status step "Path: $filepath"
print_status step "============================================================"
# ------------------------------------------------------------------
# Phase 1: Identify Sonarr vs Radarr
# ------------------------------------------------------------------
local arr_type
if [[ -n "$FORCE_TYPE" ]]; then
arr_type="$FORCE_TYPE"
print_status info "Type forced: $arr_type"
elif [[ "$filepath" == *"/Series/"* ]] || [[ "$filepath" == *"/TV Shows/"* ]] || [[ "$filepath" == *"/TV/"* ]]; then
arr_type="sonarr"
elif [[ "$filepath" == *"/Movies/"* ]] || [[ "$filepath" == *"/Movie/"* ]]; then
arr_type="radarr"
else
print_status error "Cannot determine type from path — use --sonarr or --radarr"
return 1
fi
local arr_url arr_key
if [[ "$arr_type" == "sonarr" ]]; then
arr_url="$SONARR_URL"; arr_key="$SONARR_API_KEY"
print_status info "Type: TV Show → Sonarr ($arr_url)"
else
arr_url="$RADARR_URL"; arr_key="$RADARR_API_KEY"
print_status info "Type: Movie → Radarr ($arr_url)"
fi
if [[ -z "$arr_key" ]]; then
print_status error "API key not configured for $arr_type. Set in $CONFIG_FILE"
return 1
fi
# ------------------------------------------------------------------
# Phase 2: Parse file path via *arr API
# ------------------------------------------------------------------
print_status step "Parsing file path via ${arr_type^} API..."
# *arr parse API only works reliably with ?title= (the filename stem).
# The ?path= parameter silently returns empty even for tracked files.
local filename stem encoded_title
filename=$(basename "$filepath")
stem="${filename%.*}"
encoded_title=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$stem" 2>/dev/null \
|| printf '%s' "$stem" | sed 's/ /+/g')
local parse_response
if ! parse_response=$(arr_get "$arr_url" "$arr_key" "parse?title=${encoded_title}"); then
print_status error "Parse API call failed — check URL and API key"
print_status info "Endpoint: ${arr_url}/api/v3/parse?title=${stem}"
return 1
fi
print_status debug "Parse response: $parse_response"
local media_id file_id media_title
if [[ "$arr_type" == "sonarr" ]]; then
media_id=$(echo "$parse_response" | jq -r '.episodes[0].id | select(. != null and . != 0) // empty')
file_id=$(echo "$parse_response" | jq -r '.episodes[0].episodeFileId | select(. != null and . != 0) // empty')
local series_id season_number episode_monitored
series_id=$(echo "$parse_response" | jq -r '.series.id // empty')
season_number=$(echo "$parse_response" | jq -r '.episodes[0].seasonNumber // empty')
episode_monitored=$(echo "$parse_response" | jq -r '.episodes[0].monitored | tostring')
media_title=$(echo "$parse_response" | jq -r '
(.series.title // "Unknown") + " " +
(.episodes[0].seasonNumber | tostring | "S" + if length == 1 then "0"+. else . end) +
(.episodes[0].episodeNumber | tostring | "E" + if length == 1 then "0"+. else . end)
' 2>/dev/null || echo "Unknown")
if [[ -z "$media_id" ]]; then
print_status error "Episode not found in Sonarr — file may not be tracked"
print_status info "Tip: check that Sonarr's root folder covers $filepath"
return 1
fi
print_status success "Found: $media_title (episodeId=$media_id, fileId=${file_id:-none}, seriesId=$series_id)"
if [[ "$episode_monitored" == "false" ]]; then
print_status warning "Episode is unmonitored — will temporarily re-monitor for replacement, then unmonitor again"
fi
else
media_id=$(echo "$parse_response" | jq -r '.movie.id | select(. != null and . != 0) // empty')
file_id=$(echo "$parse_response" | jq -r '.movie.movieFileId | select(. != null and . != 0) // empty')
media_title=$(echo "$parse_response" | jq -r '.movie.title // "Unknown"')
if [[ -z "$media_id" ]]; then
print_status error "Movie not found in Radarr — file may not be tracked"
return 1
fi
print_status success "Found: $media_title (movieId=$media_id, fileId=${file_id:-none})"
fi
# ------------------------------------------------------------------
# Phase 3: Check download queue for a pending/completed import
# ------------------------------------------------------------------
if [[ "$SEARCH_ONLY" != "true" ]]; then
print_status step "Checking download queue for available import..."
local queue_response queue_count
if [[ "$arr_type" == "sonarr" ]]; then
queue_response=$(arr_get "$arr_url" "$arr_key" "queue?seriesId=${series_id}&includeEpisode=true&pageSize=50" 2>/dev/null || echo '{"records":[]}')
queue_count=$(echo "$queue_response" | jq '[.records[] | select(.episode.id == '"$media_id"')] | length')
else
queue_response=$(arr_get "$arr_url" "$arr_key" "queue?movieId=${media_id}&pageSize=50" 2>/dev/null || echo '{"records":[]}')
queue_count=$(echo "$queue_response" | jq '[.records[]] | length')
fi
print_status debug "Queue entries matching: $queue_count"
if [[ "${queue_count:-0}" -gt 0 ]]; then
local queue_status
queue_status=$(echo "$queue_response" | jq -r '.records[0].status // "unknown"')
local tracked_state
tracked_state=$(echo "$queue_response" | jq -r '.records[0].trackedDownloadState // "unknown"')
print_status success "Found in queue (status=$queue_status, trackedState=$tracked_state)"
if [[ "$queue_status" == "completed" ]] || [[ "$tracked_state" == "importPending" ]]; then
print_status step "Original download is ready — triggering import scan..."
if [[ "$arr_type" == "sonarr" ]]; then
arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"DownloadedEpisodesScan\", \"seriesId\": $series_id}" >/dev/null
else
arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"DownloadedMoviesScan\"}" >/dev/null
fi
print_status success "Import scan triggered — check ${arr_type^} activity feed"
return 0
elif [[ "$queue_status" == "downloading" ]] || [[ "$tracked_state" == "downloading" ]]; then
print_status success "Download already in progress — watching for completion..."
local should_unmonitor=false
[[ "$arr_type" == "sonarr" ]] && [[ "${episode_monitored:-true}" == "false" ]] && should_unmonitor=true
echo "__WATCH__|${arr_type}|${media_id}|${should_unmonitor}|${media_title}"
return 0
else
print_status info "Item in queue but not yet ready (status=$queue_status) — will check history"
fi
else
print_status info "Not found in download queue"
fi
# ------------------------------------------------------------------
# Phase 4: Check history → find torrent hash → check qBittorrent
# ------------------------------------------------------------------
print_status step "Checking history for original torrent hash..."
local history_response history_hashes
if [[ "$arr_type" == "sonarr" ]]; then
history_response=$(arr_get "$arr_url" "$arr_key" "history?episodeId=${media_id}&eventType=grabbed&pageSize=10" 2>/dev/null || echo '{"records":[]}')
else
history_response=$(arr_get "$arr_url" "$arr_key" "history?movieId=${media_id}&eventType=grabbed&pageSize=10" 2>/dev/null || echo '{"records":[]}')
fi
# Extract torrent hashes from history, most recent first
readarray -t history_hashes < <(echo "$history_response" | jq -r '.records[].downloadId // empty' | tr '[:upper:]' '[:lower:]' | grep -v '^$' || true)
# Name-based fallback: when there's no grab history, search qBit by series + season.
# Builds a keyword from the series folder name and the season number extracted from
# the "Season N" parent directory. Both series title and torrent name are normalised
# (punctuation → spaces) before matching so "Show.S03.x264" and "Show S03 x264" both hit.
if [[ ${#history_hashes[@]} -eq 0 ]]; then
local series_dir_kw season_tag series_dir_keyword
series_dir_kw=$(basename "$(dirname "$(dirname "$filepath")")" \
| tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 ]/ /g' | awk '{print $1,$2,$3}')
local season_num
season_num=$(basename "$(dirname "$filepath")" | grep -oP '\d+' | head -1)
season_tag=$(printf 's%02d' "${season_num:-0}")
series_dir_keyword="${series_dir_kw} ${season_tag}"
print_status info "No grab history — trying name-based qBittorrent search (keyword: '$series_dir_keyword')..."
for qbit_url in "${QBIT_INSTANCES[@]}"; do
local cj all_torrents nm_matches nm_count
cj=$(qbit_login "$qbit_url")
[[ -z "$cj" ]] && continue
if [[ "$cj" == "bypass" ]]; then
all_torrents=$(curl -sf --max-time 15 "${qbit_url}/api/v2/torrents/info" 2>/dev/null || echo '[]')
else
all_torrents=$(curl -sf --max-time 15 -b "$cj" "${qbit_url}/api/v2/torrents/info" 2>/dev/null || echo '[]')
rm -f "$cj"
fi
# Normalise torrent names: punctuation → spaces, lowercase, then substring match.
# This handles both "Show.S03.x264" and "Show S03 x264" styles.
nm_matches=$(echo "$all_torrents" | jq --arg kw "$series_dir_keyword" \
'[.[] | select(.name | ascii_downcase | gsub("[^a-z0-9]"; " ") | contains($kw))]' \
2>/dev/null || echo '[]')
nm_count=$(echo "$nm_matches" | jq 'length')
if [[ "${nm_count:-0}" -gt 0 ]]; then
local nm_name nm_hash nm_state
nm_name=$(echo "$nm_matches" | jq -r '.[0].name')
nm_hash=$(echo "$nm_matches" | jq -r '.[0].hash')
nm_state=$(echo "$nm_matches" | jq -r '.[0].state // "unknown"')
print_status success "Found by name in qBit ($qbit_url): $nm_name (state=$nm_state)"
history_hashes=("$nm_hash")
break
fi
done
[[ ${#history_hashes[@]} -eq 0 ]] && print_status info "Not found in qBittorrent by name either"
fi
if [[ ${#history_hashes[@]} -gt 0 ]]; then
print_status info "Checking qBittorrent for ${#history_hashes[@]} candidate hash(es)..."
local found_in_qbit=false
local found_qbit_url="" found_hash="" found_save_path="" found_torrent_name=""
local cookie_jar=""
for qbit_url in "${QBIT_INSTANCES[@]}"; do
print_status debug "Checking qBit instance: $qbit_url"
cookie_jar=$(qbit_login "$qbit_url")
if [[ -z "$cookie_jar" ]]; then
print_status debug " Login failed or not reachable: $qbit_url"
continue
fi
for hash in "${history_hashes[@]}"; do
local torrent_info torrent_count
torrent_info=$(qbit_check_hash "$qbit_url" "$cookie_jar" "$hash")
torrent_count=$(echo "$torrent_info" | jq 'length' 2>/dev/null || echo 0)
if [[ "${torrent_count:-0}" -gt 0 ]]; then
local torrent_state torrent_name
torrent_state=$(echo "$torrent_info" | jq -r '.[0].state // "unknown"')
torrent_name=$(echo "$torrent_info" | jq -r '.[0].name // "unknown"')
found_save_path=$(echo "$torrent_info" | jq -r '.[0].save_path // empty')
print_status success "Found in qBittorrent! ($qbit_url)"
print_status info " Torrent: $torrent_name"
print_status info " State: $torrent_state"
print_status info " Path: ${found_save_path:-unknown}"
found_in_qbit=true
found_qbit_url="$qbit_url"
found_hash="$hash"
found_torrent_name="$torrent_name"
break 2
fi
done
[[ "$cookie_jar" != "bypass" ]] && rm -f "$cookie_jar"
done
if [[ "$found_in_qbit" == "true" ]]; then
print_status step "Original torrent still available — deleting corrupted file record..."
# Re-monitor so Sonarr will accept the import (async — watcher will re-unmonitor)
if [[ "$arr_type" == "sonarr" ]] && [[ "${episode_monitored:-true}" == "false" ]]; then
remonitor_episode "$arr_url" "$arr_key" "$media_id" "true"
fi
if [[ -n "$file_id" ]]; then
if [[ "$arr_type" == "sonarr" ]]; then
arr_delete "$arr_url" "$arr_key" "episodefile/$file_id"
else
arr_delete "$arr_url" "$arr_key" "moviefile/$file_id"
fi
print_status success "File record deleted from ${arr_type^}"
else
print_status warning "No file ID found — skipping delete (file may already be untracked)"
fi
# Use path-based scan — works even when the qBit instance is not registered
# as a download client in Sonarr (hash-based scan requires registration).
local content_path="${found_save_path%/}/${found_torrent_name}"
print_status step "Triggering path-based import scan: $content_path"
if [[ "$arr_type" == "sonarr" ]]; then
arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"DownloadedEpisodesScan\", \"path\": \"$content_path\"}" >/dev/null
else
arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"DownloadedMoviesScan\", \"path\": \"$content_path\"}" >/dev/null
fi
print_status success "Import scan triggered — watching for completion..."
# Signal server to watch for import and auto-unmonitor
local should_unmonitor=false
[[ "$arr_type" == "sonarr" ]] && [[ "${episode_monitored:-true}" == "false" ]] && should_unmonitor=true
echo "__WATCH__|${arr_type}|${media_id}|${should_unmonitor}|${media_title}"
return 0
else
print_status info "Original torrent not found in any qBittorrent instance"
fi
fi
fi
# ------------------------------------------------------------------
# Phase 5: Fallback — delete file record and trigger automatic search
# ------------------------------------------------------------------
print_status step "Original not available — deleting corrupted file record and triggering search..."
# Must be monitored for Sonarr to accept the downloaded replacement
if [[ "$arr_type" == "sonarr" ]] && [[ "${episode_monitored:-true}" == "false" ]]; then
remonitor_episode "$arr_url" "$arr_key" "$media_id" "true"
fi
if [[ -n "$file_id" ]]; then
if [[ "$arr_type" == "sonarr" ]]; then
arr_delete "$arr_url" "$arr_key" "episodefile/$file_id"
else
arr_delete "$arr_url" "$arr_key" "moviefile/$file_id"
fi
print_status success "File record deleted from ${arr_type^}"
else
print_status warning "No file ID to delete — item may already be untracked"
fi
PICKED_SEASON_PACK=false
if ! pick_best_release "$arr_url" "$arr_key" "$arr_type" "$media_id" "${series_id:-}" "${season_number:-}"; then
print_status warning "Scored search failed — falling back to automatic search..."
local search_result
if [[ "$arr_type" == "sonarr" ]]; then
search_result=$(arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"EpisodeSearch\", \"episodeIds\": [$media_id]}")
else
search_result=$(arr_post "$arr_url" "$arr_key" "command" \
"{\"name\": \"MoviesSearch\", \"movieIds\": [$media_id]}")
fi
local cmd_id
cmd_id=$(echo "$search_result" | jq -r '.id // "?"')
print_status success "Automatic search triggered (command ID: $cmd_id)"
fi
# Season pack grabbed — delete all existing episode file records for this season
# so Sonarr treats every slot as empty and imports all files from the download.
# Without this, Sonarr only replaces files that score higher in the quality profile.
if [[ "$PICKED_SEASON_PACK" == "true" ]] && [[ "$arr_type" == "sonarr" ]] \
&& [[ -n "$series_id" ]] && [[ -n "$season_number" ]]; then
print_status step "Season pack grabbed — clearing all episode file records for S$(printf '%02d' "$season_number")..."
# Fetch all episodes in this season that have a file
local season_episodes
season_episodes=$(curl -sf --max-time 30 \
-H "X-Api-Key: $arr_key" \
"${arr_url}/api/v3/episode?seriesId=${series_id}&seasonNumber=${season_number}" \
2>/dev/null || echo '[]')
local cleared=0
while IFS= read -r ep_file_id; do
[[ -z "$ep_file_id" || "$ep_file_id" == "null" || "$ep_file_id" == "0" ]] && continue
[[ "$ep_file_id" == "$file_id" ]] && continue # already deleted above
arr_delete "$arr_url" "$arr_key" "episodefile/$ep_file_id" && (( cleared++ )) || true
done < <(echo "$season_episodes" | jq -r '.[] | select(.hasFile == true) | .episodeFileId // empty')
print_status success "Cleared $cleared additional episode file record(s) — season will be fully replaced"
fi
# Signal server to poll for import completion and auto-unmonitor when done
local should_unmonitor=false
[[ "$arr_type" == "sonarr" ]] && [[ "${episode_monitored:-true}" == "false" ]] && should_unmonitor=true
echo "__WATCH__|${arr_type}|${media_id}|${should_unmonitor}|${media_title}"
return 0
}
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Manual unmonitor helper (run after replacement has downloaded)
# ---------------------------------------------------------------------------
if [[ -n "$UNMONITOR_EPISODE_ID" ]]; then
print_status info "=== recovarr.sh: unmonitor episode $UNMONITOR_EPISODE_ID ==="
if [[ -z "$SONARR_API_KEY" ]]; then
print_status error "SONARR_API_KEY not configured"
exit 1
fi
remonitor_episode "$SONARR_URL" "$SONARR_API_KEY" "$UNMONITOR_EPISODE_ID" "false"
print_status success "Episode $UNMONITOR_EPISODE_ID unmonitored — curation restored"
exit 0
fi
# ---------------------------------------------------------------------------
# Recovery wrapper: catches failures and prints manual fallback instructions
# ---------------------------------------------------------------------------
run_recovery() {
local filepath="$1"
if recover_file "$filepath"; then
return 0
fi
echo "" >&2
print_status error "======================================================"
print_status error "RECOVERY FAILED — manual intervention required"
print_status error "======================================================"
print_status error "File: $filepath"
echo "" >&2
print_status info "Manual steps:"
print_status info " 1. Open Sonarr/Radarr and find the episode/movie"
print_status info " 2. If the corrupted file is still tracked:"
print_status info " - Go to the episode → click the file icon → Delete"
print_status info " 3. Re-enable monitoring on the episode (temporarily)"
print_status info " 4. Click the search icon to trigger a manual search"
print_status info " 5. Once replacement downloads, unmonitor the episode again"
echo "" >&2
print_status info "Or re-run with verbose output for more detail:"
print_status info " $0 --verbose \"$filepath\""
return 1
}
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
if [[ "$BATCH_MODE" == "true" ]]; then
if [[ -z "$BATCH_FILE" ]] || [[ ! -f "$BATCH_FILE" ]]; then
print_status error "Batch file not found: $BATCH_FILE"
exit 1
fi
print_status info "=== recovarr.sh batch mode: $BATCH_FILE ==="
[[ "$DRY_RUN" == "true" ]] && print_status warning "DRY-RUN mode — no changes will be made"
PASS=0; FAIL=0
FAILED_FILES=()
while IFS= read -r line; do
[[ -z "$line" || "$line" == \#* ]] && continue
if run_recovery "$line"; then
((PASS++)) || true
else
((FAIL++)) || true
FAILED_FILES+=("$line")
fi
done < "$BATCH_FILE"
echo "" >&2
print_status info "=== Batch complete: $PASS succeeded, $FAIL failed ==="
if [[ $FAIL -gt 0 ]]; then
echo "" >&2
print_status warning "Files requiring manual attention:"
for f in "${FAILED_FILES[@]}"; do
print_status warning " $f"
done
exit 1
fi
else
if [[ ${#POSITIONAL[@]} -eq 0 ]]; then
usage
fi
print_status info "=== recovarr.sh ==="
[[ "$DRY_RUN" == "true" ]] && print_status warning "DRY-RUN mode — no changes will be made"
run_recovery "${POSITIONAL[0]}"
fi