feat: initial public release — web UI for re-triggering Sonarr/Radarr imports
This commit is contained in:
commit
4546cd38fb
7 changed files with 2014 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
12
LICENSE
Normal 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
92
README.md
Normal 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
14
package.json
Normal 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
457
public/index.html
Normal 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 /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
871
recovarr.sh
Executable 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
555
server.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue