commit 4546cd38fbfca10e1eebb0cc6df7ec635f55e39a Author: pyr0ball Date: Tue May 26 15:19:21 2026 -0700 feat: initial public release — web UI for re-triggering Sonarr/Radarr imports diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e5313 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +*.log +*.bak + +# Runtime data (generated, not source) +pending-queue.json +jobs.log + +# Sensitive config +api-keys.conf +.env +.env.* +!.env.example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a52574a --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2026 CircuitForge LLC + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + + PREAMBLE + +The GNU General Public License is a free, copyleft license for software and other +kinds of works. For the full license text, see https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..01cd973 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Recovarr + +> Web UI for re-triggering Sonarr/Radarr imports on corrupted or missing media files. + +Recovarr queues file paths for `recovarr.sh`, streams live script output via Server-Sent Events (SSE), polls Sonarr/Radarr for import completion, and auto-unmonitors episodes/movies once the download lands. + +--- + +## What it does + +Given a path to a corrupted or missing media file, Recovarr: + +1. Identifies the media in Sonarr (TV) or Radarr (Movies) via the parse API +2. Checks the download queue for a pending import +3. Checks download history to see if the original torrent is still available +4. If available: deletes the file record and triggers an import scan +5. If not available: deletes the file record and triggers an automatic search +6. Polls every 30s until the import completes, then auto-unmonitors + +--- + +## Requirements + +- Node.js 18+ +- Bash 4+, curl, jq (for `recovarr.sh`) +- No npm dependencies — pure Node.js built-ins only + +--- + +## Install + +```bash +git clone https://git.opensourcesolarpunk.com/Circuit-Forge/recovarr +cd recovarr +``` + +### Config + +```bash +mkdir -p ~/.config/media-postprocessor +cat > ~/.config/media-postprocessor/api-keys.conf <=18" + }, + "license": "GPL-3.0", + "dependencies": {} +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..df27eb4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,457 @@ + + + + + +Recovarr + + + +
+

Recovarr recovarr

+ +
+ + +
+ + + +
+
+ +
+

Queue

+ +
+
+
No jobs yet — paste a file path above
+
+ +
+

History

+ ▾ Show +
+ +
+ + + + diff --git a/recovarr.sh b/recovarr.sh new file mode 100755 index 0000000..0a6caad --- /dev/null +++ b/recovarr.sh @@ -0,0 +1,871 @@ +#!/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 [options] +# ./recovarr.sh --batch [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 [--dry-run] [--verbose] [--search-only] [--sonarr|--radarr]" + echo " $0 --batch [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 diff --git a/server.js b/server.js new file mode 100644 index 0000000..a440ae2 --- /dev/null +++ b/server.js @@ -0,0 +1,555 @@ +#!/usr/bin/env node +// +// server.js - Recovarr web UI backend +// Relative Path: ./projects/recovarr/server.js +// +// Purpose: Minimal HTTP server (no npm deps) that queues file paths for +// recovarr.sh, streams live output via SSE, polls Sonarr/Radarr for +// import completion, and auto-unmonitors when done. +// +// License: GPL-3.0 + +'use strict'; + +const http = require('http'); +const https = require('https'); +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const PORT = parseInt(process.env.PORT || '8602', 10); +const SCRIPT = process.env.ARR_RECOVER_SCRIPT + || path.resolve(__dirname, '../../scripts/recovarr.sh'); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const CONFIG_PATH = process.env.ARR_RECOVER_CONFIG + || path.join(os.homedir(), '.config/media-postprocessor/api-keys.conf'); +const LOG_PATH = process.env.ARR_RECOVER_LOG + || path.join(os.homedir(), '.local/share/recovarr/jobs.log'); +const QUEUE_PATH = process.env.ARR_RECOVER_QUEUE + || path.join(os.homedir(), '.local/share/recovarr/pending-queue.json'); +const LOG_MAX_ENTRIES = 200; + +const POLL_INTERVAL_MS = 30_000; // check Sonarr/Radarr every 30s +const WATCH_TIMEOUT_MS = 24 * 60 * 60 * 1000; // give up after 24h + +function loadConfig() { + try { + const lines = fs.readFileSync(CONFIG_PATH, 'utf8').split('\n'); + const cfg = {}; + for (const line of lines) { + const m = line.match(/^([A-Z_]+)=(.+)$/); + if (m) cfg[m[1]] = m[2].trim(); + } + return cfg; + } catch { + console.warn(`Config not found at ${CONFIG_PATH} — watcher disabled`); + return {}; + } +} + +let cfg = loadConfig(); + +// --------------------------------------------------------------------------- +// Pending queue — survive restarts by persisting queued/watching jobs +// --------------------------------------------------------------------------- +function savePendingQueue() { + try { + const dir = path.dirname(QUEUE_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const pending = [...jobs.values()] + .filter(j => !j.archived && (j.status === 'queued' || j.status === 'running' || j.status === 'watching')) + .map(j => ({ id: j.id, filepath: j.filepath, createdAt: j.createdAt })); + fs.writeFileSync(QUEUE_PATH, JSON.stringify(pending, null, 2)); + } catch (err) { + console.warn('Failed to save pending queue:', err.message); + } +} + +function loadPendingQueue() { + try { + if (!fs.existsSync(QUEUE_PATH)) return; + const pending = JSON.parse(fs.readFileSync(QUEUE_PATH, 'utf8')); + if (!Array.isArray(pending) || pending.length === 0) return; + let restored = 0; + for (const entry of pending) { + if (!entry.filepath || jobs.has(entry.id)) continue; + // Re-create as queued — the script will re-run full recovery logic + const job = { + id: entry.id, + filepath: entry.filepath, + status: 'queued', + lines: [{ text: '[recovarr] Re-queued after server restart', ts: Date.now() }], + exitCode: null, + createdAt: entry.createdAt || Date.now(), + watchData: null, + sseClients: new Set(), + }; + jobs.set(job.id, job); + restored++; + } + if (restored) console.log(`Restored ${restored} pending job(s) from queue`); + } catch (err) { + console.warn('Failed to load pending queue:', err.message); + } +} + +// --------------------------------------------------------------------------- +// Job log — persist finished jobs to ~/.local/share/recovarr/jobs.log +// --------------------------------------------------------------------------- +function appendJobLog(job) { + try { + const dir = path.dirname(LOG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const record = JSON.stringify({ + id: job.id, + filepath: job.filepath, + status: job.status, + exitCode: job.exitCode, + createdAt: job.createdAt, + finishedAt: Date.now(), + watchData: job.watchData, + lines: job.lines, + }); + fs.appendFileSync(LOG_PATH, record + '\n'); + } catch (err) { + console.warn('Failed to write job log:', err.message); + } +} + +function loadJobLog() { + try { + if (!fs.existsSync(LOG_PATH)) return; + const raw = fs.readFileSync(LOG_PATH, 'utf8').split('\n').filter(Boolean); + const recent = raw.slice(-LOG_MAX_ENTRIES); + let loaded = 0; + for (const line of recent) { + try { + const r = JSON.parse(line); + if (!r.id || jobs.has(r.id)) continue; + jobs.set(r.id, { ...r, archived: true, sseClients: new Set() }); + loaded++; + } catch { /* skip malformed */ } + } + if (loaded) console.log(`Loaded ${loaded} archived job(s) from log`); + } catch (err) { + console.warn('Failed to read job log:', err.message); + } +} + +function removeFromLog(id) { + try { + if (!fs.existsSync(LOG_PATH)) return; + const lines = fs.readFileSync(LOG_PATH, 'utf8').split('\n').filter(Boolean); + const filtered = lines.filter(line => { + try { return JSON.parse(line).id !== id; } + catch { return true; } + }); + fs.writeFileSync(LOG_PATH, filtered.length ? filtered.join('\n') + '\n' : ''); + } catch (err) { + console.warn('Failed to update job log:', err.message); + } +} + +// --------------------------------------------------------------------------- +// Job model +// --------------------------------------------------------------------------- +// status: queued | running | watching | restored | timeout | done | failed +const jobs = new Map(); + +function createJob(filepath) { + const id = crypto.randomBytes(6).toString('hex'); + const job = { + id, + filepath, + status: 'queued', + lines: [], + exitCode: null, + createdAt: Date.now(), + watchData: null, // { type, mediaId, unmonitor, title } — set from __WATCH__ line + sseClients: new Set(), + }; + jobs.set(id, job); + savePendingQueue(); + return job; +} + +function pushLine(job, line) { + job.lines.push(line); + for (const res of job.sseClients) { + res.write(`data: ${JSON.stringify(line)}\n\n`); + } +} + +function sendEvent(job, event, data) { + for (const res of job.sseClients) { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } +} + +function finishJob(job, exitCode) { + job.exitCode = exitCode; + if (job.status !== 'restored' && job.status !== 'timeout') { + job.status = exitCode === 0 ? 'done' : 'failed'; + } + sendEvent(job, 'done', { exitCode, status: job.status, watchData: job.watchData }); + for (const res of job.sseClients) res.end(); + job.sseClients.clear(); + appendJobLog(job); + savePendingQueue(); // remove from pending now that it's finished +} + +// --------------------------------------------------------------------------- +// Arr API helpers (used by the watcher — plain Node http, no npm) +// --------------------------------------------------------------------------- +function arrRequest(method, baseUrl, apiKey, endpoint, body) { + return new Promise((resolve, reject) => { + const url = new URL(`${baseUrl}/api/v3/${endpoint}`); + const isHttps = url.protocol === 'https:'; + const lib = isHttps ? https : http; + + const payload = body ? JSON.stringify(body) : null; + const options = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + (url.search || ''), + method, + headers: { + 'X-Api-Key': apiKey, + 'Accept': 'application/json', + ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + timeout: 15_000, + }; + + const req = lib.request(options, (res) => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } + catch { resolve(null); } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + if (payload) req.write(payload); + req.end(); + }); +} + +const arrGet = (url, key, ep) => arrRequest('GET', url, key, ep); +const arrPost = (url, key, ep, body) => arrRequest('POST', url, key, ep, body); +const arrPut = (url, key, ep, body) => arrRequest('PUT', url, key, ep, body); + +// --------------------------------------------------------------------------- +// Watcher — polls for import completion, then unmonitors +// --------------------------------------------------------------------------- +function startWatcher(job) { + cfg = loadConfig(); // re-read in case config was updated + const { type, mediaId, unmonitor } = job.watchData; + + if (!mediaId || mediaId === 0 || !Number.isInteger(mediaId)) { + pushLine(job, `[WATCH] Invalid mediaId (${mediaId}) — cannot watch`); + finishJob(job, 1); + return; + } + + const apiUrl = type === 'sonarr' ? cfg.SONARR_URL : cfg.RADARR_URL; + const apiKey = type === 'sonarr' ? cfg.SONARR_API_KEY : cfg.RADARR_API_KEY; + + if (!apiUrl || !apiKey) { + pushLine(job, '[WATCH] API credentials not found in config — cannot auto-watch'); + finishJob(job, 1); + return; + } + + const endpoint = type === 'sonarr' ? `episode/${mediaId}` : `movie/${mediaId}`; + let elapsed = 0; + pushLine(job, `[WATCH] Polling ${type} every 30s — waiting for download + import to complete`); + + job.watcherTimer = setInterval(async () => { + elapsed += POLL_INTERVAL_MS; + + if (elapsed > WATCH_TIMEOUT_MS) { + clearInterval(job.watcherTimer); + job.status = 'timeout'; + pushLine(job, '[WATCH] Timed out after 24h — no import detected'); + pushLine(job, '[WATCH] Check Sonarr/Radarr activity for errors'); + finishJob(job, 1); + return; + } + + try { + const media = await arrGet(apiUrl, apiKey, endpoint); + + if (media && media.hasFile) { + clearInterval(job.watcherTimer); + pushLine(job, '[WATCH] Import confirmed'); + + if (unmonitor && type === 'sonarr') { + await arrPut(apiUrl, apiKey, 'episode/monitor', + { episodeIds: [mediaId], monitored: false }); + pushLine(job, '[WATCH] Episode unmonitored — curation preserved'); + } else if (unmonitor && type === 'radarr') { + const movie = await arrGet(apiUrl, apiKey, `movie/${mediaId}`); + if (movie) { + movie.monitored = false; + await arrPut(apiUrl, apiKey, `movie/${mediaId}`, movie); + pushLine(job, '[WATCH] Movie unmonitored — curation preserved'); + } + } + + job.status = 'restored'; + finishJob(job, 0); + } else { + const mins = Math.floor(elapsed / 60_000); + pushLine(job, `[WATCH] Still waiting for download... (${mins}m elapsed)`); + sendEvent(job, 'status', { status: 'watching', elapsed }); + } + } catch (err) { + pushLine(job, `[WATCH] Poll error: ${err.message}`); + } + }, POLL_INTERVAL_MS); +} + +// --------------------------------------------------------------------------- +// Script runner +// --------------------------------------------------------------------------- +function runJob(job) { + job.status = 'running'; + + const proc = spawn('bash', [SCRIPT, job.filepath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, TERM: 'dumb' }, // suppress color codes + }); + + const handleData = (data) => { + const raw = data.toString(); + // Strip ANSI codes + const clean = raw.replace(/\x1b\[[0-9;]*m/g, ''); + for (const line of clean.split('\n')) { + if (!line.trim()) continue; + + // Parse watch signal from script + if (line.startsWith('__WATCH__|')) { + const parts = line.split('|'); + // __WATCH__|type|mediaId|unmonitor|title + job.watchData = { + type: parts[1], + mediaId: parseInt(parts[2], 10), + unmonitor: parts[3] === 'true', + title: parts.slice(4).join('|'), + }; + continue; // don't push this as a visible log line + } + + pushLine(job, line); + } + }; + + proc.stdout.on('data', handleData); + proc.stderr.on('data', handleData); + + proc.on('close', (code) => { + pushLine(job, `[recovarr] Exited with code ${code ?? 1}`); + + if (code === 0 && job.watchData) { + job.status = 'watching'; + sendEvent(job, 'watching', { watchData: job.watchData }); + pushLine(job, `[WATCH] Polling ${job.watchData.type} every 30s for import...`); + startWatcher(job); + } else { + finishJob(job, code ?? 1); + } + }); + + proc.on('error', (err) => { + pushLine(job, `[recovarr] Failed to start: ${err.message}`); + finishJob(job, 1); + }); +} + +// --------------------------------------------------------------------------- +// Queue — one job at a time +// --------------------------------------------------------------------------- +let running = false; + +function processQueue() { + if (running) return; + for (const job of jobs.values()) { + if (job.status === 'queued') { + running = true; + runJob(job); + const poll = setInterval(() => { + if (job.status !== 'running') { + clearInterval(poll); + running = false; + processQueue(); + } + }, 300); + break; + } + } +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- +function serveFile(res, filePath, contentType) { + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); +} + +function json(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks).toString())); + req.on('error', reject); + }); +} + +function jobSummary(job) { + return { + id: job.id, + filepath: job.filepath, + status: job.status, + exitCode: job.exitCode, + lineCount: job.lines.length, + createdAt: job.createdAt, + finishedAt: job.finishedAt || null, + watchData: job.watchData, + archived: job.archived || false, + }; +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const p = url.pathname.replace(/\/+$/, '') || '/'; + + res.setHeader('Access-Control-Allow-Origin', '*'); + + // Static UI + if (req.method === 'GET' && p === '/') { + return serveFile(res, path.join(PUBLIC_DIR, 'index.html'), 'text/html'); + } + + // POST /api/recover + if (req.method === 'POST' && p === '/api/recover') { + let body; + try { body = JSON.parse(await readBody(req)); } + catch { return json(res, 400, { error: 'Invalid JSON' }); } + + const paths = (body.paths || []).map(s => s.trim()).filter(Boolean); + if (!paths.length) return json(res, 400, { error: 'No paths provided' }); + + const created = paths.map(fp => jobSummary(createJob(fp))); + processQueue(); + return json(res, 202, { jobs: created }); + } + + // GET /api/jobs + if (req.method === 'GET' && p === '/api/jobs') { + return json(res, 200, + [...jobs.values()].sort((a, b) => b.createdAt - a.createdAt).map(jobSummary)); + } + + // GET /api/jobs/:id + const jobMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)$/); + if (req.method === 'GET' && jobMatch) { + const job = jobs.get(jobMatch[1]); + if (!job) return json(res, 404, { error: 'Not found' }); + return json(res, 200, { ...jobSummary(job), lines: job.lines }); + } + + // GET /api/jobs/:id/stream (SSE) + const streamMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)\/stream$/); + if (req.method === 'GET' && streamMatch) { + const job = jobs.get(streamMatch[1]); + if (!job) return json(res, 404, { error: 'Not found' }); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + // Replay existing lines + for (const line of job.lines) { + res.write(`data: ${JSON.stringify(line)}\n\n`); + } + + const isActive = job.status === 'running' || job.status === 'watching' || job.status === 'queued'; + if (!isActive) { + res.write(`event: done\ndata: ${JSON.stringify({ exitCode: job.exitCode, status: job.status, watchData: job.watchData })}\n\n`); + res.end(); + return; + } + + if (job.watchData && job.status === 'watching') { + res.write(`event: watching\ndata: ${JSON.stringify({ watchData: job.watchData })}\n\n`); + } + + job.sseClients.add(res); + req.on('close', () => job.sseClients.delete(res)); + return; + } + + // POST /api/jobs/:id/retry + const retryMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)\/retry$/); + if (req.method === 'POST' && retryMatch) { + const job = jobs.get(retryMatch[1]); + if (!job) return json(res, 404, { error: 'Not found' }); + if (job.status === 'running' || job.status === 'watching') + return json(res, 409, { error: 'Job is active' }); + const newJob = createJob(job.filepath); + processQueue(); + return json(res, 202, jobSummary(newJob)); + } + + // DELETE /api/jobs/:id + const delMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)$/); + if (req.method === 'DELETE' && delMatch) { + const job = jobs.get(delMatch[1]); + if (!job) return json(res, 404, { error: 'Not found' }); + const force = url.searchParams.get('force') === 'true'; + if ((job.status === 'running' || job.status === 'watching') && !force) + return json(res, 409, { error: 'Job is active — use ?force=true to cancel' }); + if (job.watcherTimer) clearInterval(job.watcherTimer); + if (job.archived) removeFromLog(delMatch[1]); + jobs.delete(delMatch[1]); + return json(res, 200, { ok: true }); + } + + res.writeHead(404); res.end('Not found'); +}); + +loadJobLog(); +loadPendingQueue(); +processQueue(); // kick off any restored pending jobs + +server.listen(PORT, '0.0.0.0', () => { + console.log(`Recovarr listening on http://0.0.0.0:${PORT}`); + console.log(`Script: ${SCRIPT}`); + console.log(`Config: ${CONFIG_PATH}`); + console.log(`Job log: ${LOG_PATH}`); + console.log(`Queue: ${QUEUE_PATH}`); + if (!fs.existsSync(SCRIPT)) console.warn('WARNING: script not found'); +});