feat: initial public release — web UI for re-triggering Sonarr/Radarr imports

This commit is contained in:
pyr0ball 2026-05-26 15:19:21 -07:00
commit 4546cd38fb
7 changed files with 2014 additions and 0 deletions

13
.gitignore vendored Normal file
View file

@ -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

12
LICENSE Normal file
View file

@ -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

92
README.md Normal file
View file

@ -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 <<EOF
SONARR_URL=http://your-sonarr-host:8989/sonarr
SONARR_API_KEY=your-sonarr-api-key
RADARR_URL=http://your-radarr-host:7878/radarr
RADARR_API_KEY=your-radarr-api-key
QBIT_USER=admin
QBIT_PASS=adminadmin
EOF
```
---
## Run
```bash
node server.js
# or with overrides:
PORT=8602 ARR_RECOVER_SCRIPT=/path/to/recovarr.sh node server.js
```
Open `http://localhost:8602` in your browser. Paste one or more file paths and click **Recover**.
---
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `8602` | Web UI port |
| `ARR_RECOVER_CONFIG` | `~/.config/media-postprocessor/api-keys.conf` | Config file path |
| `ARR_RECOVER_SCRIPT` | `./recovarr.sh` | Path to the recovarr.sh script |
| `ARR_RECOVER_LOG` | `~/.local/share/recovarr/jobs.log` | Job log path |
| `ARR_RECOVER_QUEUE` | `~/.local/share/recovarr/pending-queue.json` | Pending queue path |
---
## API
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/recover` | Queue file paths `{ paths: ["/path/to/file"] }` |
| `GET` | `/api/jobs` | List all jobs |
| `GET` | `/api/jobs/:id` | Get job detail + log lines |
| `GET` | `/api/jobs/:id/stream` | SSE stream of live log output |
| `POST` | `/api/jobs/:id/retry` | Retry a failed job |
| `DELETE` | `/api/jobs/:id` | Delete a job (`?force=true` to cancel active) |
---
## License
GPL-3.0

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "recovarr",
"version": "0.1.0",
"description": "Web UI for re-triggering Sonarr/Radarr imports on corrupted or missing media files",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"license": "GPL-3.0",
"dependencies": {}
}

457
public/index.html Normal file
View file

@ -0,0 +1,457 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recovarr</title>
<style>
/* ── Theme ──────────────────────────────────────────── */
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #252836;
--border: #2e3245;
--accent: #7c6af7;
--text: #e2e4ef;
--muted: #7880a0;
--success: #4caf7d;
--warning: #e8a93a;
--error: #e85858;
--info: #4ab8e8;
--watching: #c97af7;
--restored: #5cdb95;
--radius: 10px;
--mono: 'JetBrains Mono','Fira Code','Consolas',monospace;
--ui: system-ui,-apple-system,sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f0f2f8;
--surface: #ffffff;
--surface2: #e8eaf2;
--border: #cdd0e0;
--accent: #5a4fcf;
--text: #1a1d2e;
--muted: #6068a0;
}
}
/* ── Reset ──────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--ui);background:var(--bg);color:var(--text);min-height:100vh;padding:1rem 1rem 3rem}
/* ── Layout ─────────────────────────────────────────── */
.wrap{max-width:860px;margin:0 auto}
h1{font-size:1.25rem;font-weight:600;margin-bottom:1.25rem;display:flex;align-items:center;gap:.5rem}
h1 .badge{font-size:.65rem;font-weight:600;background:#3d3580;color:#a89af7;border-radius:20px;padding:.2rem .6rem;text-transform:uppercase;letter-spacing:.05em}
/* ── Card ───────────────────────────────────────────── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin-bottom:1rem}
label{display:block;font-size:.75rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.4rem}
textarea{width:100%;min-height:80px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--mono);font-size:.8rem;padding:.6rem .7rem;resize:vertical;outline:none;line-height:1.5}
textarea:focus{border-color:var(--accent)}
textarea::placeholder{color:var(--muted);opacity:.6}
.row{display:flex;gap:.5rem;margin-top:.75rem;flex-wrap:wrap;align-items:center}
.hint{font-size:.75rem;color:var(--muted);margin-left:auto}
/* ── Buttons ────────────────────────────────────────── */
button{font-family:var(--ui);font-size:.85rem;font-weight:600;border:none;border-radius:6px;padding:.5rem 1.1rem;cursor:pointer;transition:opacity .15s,transform .1s}
button:active{transform:scale(.97)}
button:disabled{opacity:.45;cursor:not-allowed}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover:not(:disabled){opacity:.85}
.btn-ghost{background:var(--surface2);color:var(--muted);border:1px solid var(--border)}
.btn-ghost:hover{color:var(--text)}
.btn-warn{background:color-mix(in srgb,var(--warning) 15%,var(--surface));color:var(--warning);border:1px solid var(--warning)}
.btn-warn:hover{background:color-mix(in srgb,var(--warning) 25%,var(--surface))}
.btn-sm{font-size:.75rem;padding:.3rem .75rem}
/* ── Queue header ───────────────────────────────────── */
.qh{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
.qh h2{font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.empty{text-align:center;padding:2.5rem 1rem;color:var(--muted);font-size:.9rem}
/* ── Job card ───────────────────────────────────────── */
.job{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:.65rem;overflow:hidden}
.job.queued {border-left:3px solid var(--muted)}
.job.running {border-left:3px solid var(--info)}
.job.watching {border-left:3px solid var(--watching)}
.job.restored {border-left:3px solid var(--restored)}
.job.done {border-left:3px solid var(--success)}
.job.failed {border-left:3px solid var(--error)}
.job.timeout {border-left:3px solid var(--warning)}
/* ── Job header ─────────────────────────────────────── */
.jh{display:flex;align-items:center;gap:.6rem;padding:.6rem .85rem;cursor:pointer;user-select:none}
.jh:hover{background:var(--surface2)}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.job.queued .dot{background:var(--muted)}
.job.running .dot{background:var(--info);animation:pulse 1s infinite}
.job.watching .dot{background:var(--watching);animation:pulse 1.5s infinite}
.job.restored .dot{background:var(--restored)}
.job.done .dot{background:var(--success)}
.job.failed .dot{background:var(--error)}
.job.timeout .dot{background:var(--warning)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.jmeta{flex:1;min-width:0}
.jtitle{font-weight:600;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.jsub{font-size:.72rem;color:var(--muted);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:.1rem}
.jstatus{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;flex-shrink:0}
.job.queued .jstatus{color:var(--muted)}
.job.running .jstatus{color:var(--info)}
.job.watching .jstatus{color:var(--watching)}
.job.restored .jstatus{color:var(--restored)}
.job.done .jstatus{color:var(--success)}
.job.failed .jstatus{color:var(--error)}
.job.timeout .jstatus{color:var(--warning)}
.jtoggle{color:var(--muted);font-size:.7rem;flex-shrink:0}
/* ── Remediation panel ──────────────────────────────── */
.remedy{display:none;padding:.65rem .85rem;background:color-mix(in srgb,var(--surface2) 60%,var(--surface));border-top:1px solid var(--border);gap:.5rem;flex-wrap:wrap;align-items:center;font-size:.8rem;color:var(--muted)}
.remedy.visible{display:flex}
.remedy-msg{flex:1;min-width:160px}
/* ── Log pane ───────────────────────────────────────── */
.jlog{display:none;background:var(--surface2);border-top:1px solid var(--border);padding:.7rem;font-family:var(--mono);font-size:.72rem;line-height:1.6;max-height:260px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.jlog.open{display:block}
.l-ok{color:var(--success)}.l-err{color:var(--error)}.l-warn{color:var(--warning)}
.l-step{color:var(--accent)}.l-info{color:var(--info)}.l-watch{color:var(--watching)}.l-def{color:var(--text)}
/* ── History section ─────────────────────────────── */
.hist-header{display:flex;align-items:center;justify-content:space-between;margin:1.5rem 0 .5rem;cursor:pointer;user-select:none}
.hist-header h2{font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.hist-toggle{font-size:.75rem;color:var(--muted);padding:.2rem .5rem}
.hist-empty{text-align:center;padding:1.5rem 1rem;color:var(--muted);font-size:.85rem}
.job.archived{opacity:.55}
.job.archived:hover{opacity:.8;transition:opacity .15s}
.arch-tag{font-size:.6rem;font-weight:600;color:var(--muted);background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:.1rem .4rem;flex-shrink:0;text-transform:uppercase;letter-spacing:.04em}
</style>
</head>
<body>
<div class="wrap">
<h1>Recovarr <span class="badge">recovarr</span></h1>
<div class="card">
<label for="paths">File path(s) — one per line</label>
<textarea id="paths"
placeholder="/Library/Series/Show/Season 01/Episode.mkv&#10;/Library/Movies/Movie (2024)/Movie.mkv"
spellcheck="false" autocorrect="off" autocapitalize="off"></textarea>
<div class="row">
<button class="btn-primary" id="submit-btn" onclick="submitPaths()">Queue Recovery</button>
<button class="btn-ghost" onclick="document.getElementById('paths').value=''">Clear</button>
<span class="hint" id="hint"></span>
</div>
</div>
<div class="qh">
<h2>Queue</h2>
<button class="btn-ghost btn-sm" onclick="clearDone()">Clear done</button>
</div>
<div id="list">
<div class="empty" id="empty">No jobs yet — paste a file path above</div>
</div>
<div class="hist-header" id="hist-header" onclick="toggleHistory()">
<h2>History <span id="hist-count"></span></h2>
<span class="hist-toggle" id="hist-toggle">▾ Show</span>
</div>
<div id="hist-list" style="display:none">
<div class="hist-empty" id="hist-empty">No archived jobs</div>
</div>
</div>
<script>
'use strict';
// ── State ────────────────────────────────────────────
// id → { el, logEl, remedyEl, open }
const state = new Map();
const STATUS_LABEL = {
queued: 'Queued',
running: 'Searching…',
watching: 'Waiting for download…',
restored: 'Restored',
done: 'Done',
failed: 'Failed',
timeout: 'Timed out',
};
const REMEDY = {
failed: { msg: 'Search failed or API error.', actions: ['retry', 'sonarr'] },
timeout: { msg: 'No download detected after 24h. Indexers may have no results.', actions: ['retry', 'sonarr'] },
};
// ── API ──────────────────────────────────────────────
const api = {
post: (u, b) => fetch(u, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(b) }).then(r=>r.json()),
get: (u) => fetch(u).then(r=>r.json()),
del: (u) => fetch(u, { method:'DELETE' }),
};
// ── Submit ───────────────────────────────────────────
async function submitPaths() {
const raw = document.getElementById('paths').value;
const paths = raw.split('\n').map(s=>s.trim()).filter(Boolean);
if (!paths.length) return;
const btn = document.getElementById('submit-btn');
btn.disabled = true;
document.getElementById('hint').textContent = `Queuing ${paths.length}…`;
try {
const res = await api.post('/api/recover', { paths });
if (res.jobs) {
res.jobs.forEach(addCard);
document.getElementById('paths').value = '';
}
} finally {
btn.disabled = false;
document.getElementById('hint').textContent = '';
syncEmpty();
}
}
// ── Path helpers ─────────────────────────────────────
function splitPath(p) {
const parts = p.replace(/\\/g,'/').split('/');
const file = parts.pop() || '';
return { dir: parts.join('/')+'/'+'\u200b', file };
}
function titleFromWatch(watchData) {
return watchData?.title || null;
}
// ── Build card ───────────────────────────────────────
function addCard(job, archived = false) {
const { dir, file } = splitPath(job.filepath);
const title = titleFromWatch(job.watchData) || file;
const el = document.createElement('div');
el.className = `job ${job.status}${archived ? ' archived' : ''}`;
el.dataset.id = job.id;
// Header
const jh = document.createElement('div');
jh.className = 'jh';
jh.addEventListener('click', () => toggleLog(job.id));
const dot = document.createElement('div'); dot.className = 'dot';
const meta = document.createElement('div'); meta.className = 'jmeta';
const jtitle = document.createElement('div'); jtitle.className = 'jtitle'; jtitle.textContent = title;
const jsub = document.createElement('div'); jsub.className = 'jsub'; jsub.textContent = dir + file;
meta.appendChild(jtitle); meta.appendChild(jsub);
const jstatus = document.createElement('span'); jstatus.className = 'jstatus';
jstatus.textContent = STATUS_LABEL[job.status] || job.status;
const toggle = document.createElement('span'); toggle.className = 'jtoggle'; toggle.textContent = '▾';
jh.appendChild(dot); jh.appendChild(meta); jh.appendChild(jstatus);
if (archived) {
const tag = document.createElement('span'); tag.className = 'arch-tag'; tag.textContent = 'archived';
jh.appendChild(tag);
}
jh.appendChild(toggle);
// Remedy panel (hidden until needed)
const remedy = document.createElement('div'); remedy.className = 'remedy';
const remedyMsg = document.createElement('span'); remedyMsg.className = 'remedy-msg';
remedy.appendChild(remedyMsg);
// Log pane
const logEl = document.createElement('div'); logEl.className = 'jlog';
el.appendChild(jh); el.appendChild(remedy); el.appendChild(logEl);
const container = archived
? document.getElementById('hist-list')
: document.getElementById('list');
container.insertBefore(el, container.firstChild);
state.set(job.id, { el, logEl, remedyEl: remedy, remedyMsg, open: false });
if (archived) {
// Archived jobs load their saved lines directly — no live SSE needed
api.get(`/api/jobs/${job.id}`).then(full => {
if (full?.lines) full.lines.forEach(l => appendLine(job.id, l));
}).catch(() => {});
showRemedy(job.id, job.status, job.watchData);
} else {
streamJob(job);
}
}
// ── Remediation panel ────────────────────────────────
function showRemedy(id, status, watchData) {
const s = state.get(id);
if (!s) return;
const r = REMEDY[status];
if (!r) return;
s.remedyMsg.textContent = r.msg;
s.remedyEl.classList.add('visible');
for (const action of r.actions) {
if (s.remedyEl.querySelector(`[data-action="${action}"]`)) continue;
const btn = document.createElement('button');
btn.dataset.action = action;
if (action === 'retry') {
btn.className = 'btn-warn btn-sm';
btn.textContent = 'Retry search';
btn.addEventListener('click', (e) => { e.stopPropagation(); retryJob(id); });
} else if (action === 'sonarr') {
btn.className = 'btn-ghost btn-sm';
btn.textContent = 'Open Sonarr';
btn.addEventListener('click', (e) => { e.stopPropagation(); window.open('http://10.1.10.71:8989/sonarr', '_blank'); });
}
s.remedyEl.appendChild(btn);
}
}
// ── Update card status ───────────────────────────────
function setStatus(id, status, watchData) {
const s = state.get(id);
if (!s) return;
s.el.className = `job ${status}`;
s.el.querySelector('.jstatus').textContent = STATUS_LABEL[status] || status;
if (watchData?.title) s.el.querySelector('.jtitle').textContent = watchData.title;
showRemedy(id, status, watchData);
}
// ── Log line ─────────────────────────────────────────
function appendLine(id, line) {
const s = state.get(id);
if (!s) return;
const span = document.createElement('span');
span.className = classifyLine(line);
span.textContent = line + '\n';
s.logEl.appendChild(span);
if (s.open) s.logEl.scrollTop = s.logEl.scrollHeight;
}
function classifyLine(l) {
if (l.includes('[OK]') || l.includes('SUCCESS')) return 'l-ok';
if (l.includes('[ERR]')) return 'l-err';
if (l.includes('[WARN]')) return 'l-warn';
if (l.includes('[-->]')) return 'l-step';
if (l.includes('[WATCH]')) return 'l-watch';
if (l.includes('[INFO]')) return 'l-info';
return 'l-def';
}
// ── Toggle log ───────────────────────────────────────
function toggleLog(id) {
const s = state.get(id);
if (!s) return;
s.open = !s.open;
s.logEl.classList.toggle('open', s.open);
s.el.querySelector('.jtoggle').textContent = s.open ? '▴' : '▾';
if (s.open) s.logEl.scrollTop = s.logEl.scrollHeight;
}
// ── SSE ──────────────────────────────────────────────
function streamJob(job) {
const es = new EventSource(`/api/jobs/${job.id}/stream`);
es.onmessage = (e) => {
try { appendLine(job.id, JSON.parse(e.data)); }
catch { appendLine(job.id, e.data); }
};
es.addEventListener('watching', (e) => {
const d = safeJson(e.data);
setStatus(job.id, 'watching', d?.watchData);
});
es.addEventListener('done', (e) => {
const d = safeJson(e.data) || {};
setStatus(job.id, d.status || (d.exitCode === 0 ? 'done' : 'failed'), d.watchData);
es.close();
syncEmpty();
});
es.onerror = () => {
api.get(`/api/jobs/${job.id}`).then(j => {
if (j?.status && !['running','watching','queued'].includes(j.status)) {
setStatus(job.id, j.status, j.watchData);
es.close();
}
}).catch(()=>{});
};
}
// ── Retry ────────────────────────────────────────────
async function retryJob(id) {
const res = await api.post(`/api/jobs/${id}/retry`, {});
if (res.id) {
// Hide remedy panel on original
const s = state.get(id);
if (s) s.remedyEl.classList.remove('visible');
addCard(res);
syncEmpty();
}
}
// ── Clear ────────────────────────────────────────────
async function clearDone() {
const jobs = await api.get('/api/jobs').catch(()=>[]);
for (const job of (Array.isArray(jobs) ? jobs : [])) {
if (['done','restored','failed','timeout'].includes(job.status)) {
await api.del(`/api/jobs/${job.id}`);
const el = document.querySelector(`[data-id="${job.id}"]`);
if (el) el.remove();
state.delete(job.id);
}
}
syncEmpty();
syncHistory();
}
// ── History toggle ───────────────────────────────────
function toggleHistory() {
const list = document.getElementById('hist-list');
const toggle = document.getElementById('hist-toggle');
const visible = list.style.display !== 'none';
list.style.display = visible ? 'none' : 'block';
toggle.textContent = visible ? '▾ Show' : '▴ Hide';
}
function syncHistory() {
const cards = document.getElementById('hist-list').querySelectorAll('.job');
const count = cards.length;
document.getElementById('hist-count').textContent = count ? `(${count})` : '';
document.getElementById('hist-empty').style.display = count ? 'none' : '';
document.getElementById('hist-header').style.display = '';
}
function syncEmpty() {
const has = document.getElementById('list').querySelectorAll('.job').length > 0;
document.getElementById('empty').style.display = has ? 'none' : '';
}
// ── Init ─────────────────────────────────────────────
async function init() {
const jobs = await api.get('/api/jobs').catch(()=>[]);
if (!Array.isArray(jobs)) { syncEmpty(); return; }
const live = jobs.filter(j => !j.archived).slice().reverse();
const archived = jobs.filter(j => j.archived).slice().reverse();
live.forEach(j => addCard(j, false));
archived.forEach(j => addCard(j, true));
syncEmpty();
syncHistory();
}
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') submitPaths();
});
init();
</script>
</body>
</html>

871
recovarr.sh Executable file
View file

@ -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 <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

555
server.js Normal file
View file

@ -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');
});