commit c8ea76292f9357a5b33ae4387a330bda861a5ae2 Author: pyr0ball Date: Tue May 26 15:19:12 2026 -0700 feat: initial public release — disc scanning and HEVC encode queue for Sonarr/Radarr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80ee07f --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +*.log +*.bak + +# Runtime data (generated by Discarr, not source) +pending-queue.json +settings.json +jobs.log + +# Sensitive config — use api-keys.conf.example as template +api-keys.conf +.env +.env.* +!.env.example diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..d49207c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,37 @@ +title = "Discarr gitleaks config" + +[extend] +# Include gitleaks default ruleset +useDefault = true + +# Arr application API keys (32-char hex, typical for Sonarr/Radarr/etc.) +[[rules]] +id = "arr-api-key" +description = "Arr application API key in source" +regex = '''(?i)(sonarr|radarr|lidarr|readarr|prowlarr|bazarr)[_\-]?api[_\-]?key['":\s=]+[a-f0-9]{32}''' +tags = ["api-key", "arr"] + +# TMDB API key pattern +[[rules]] +id = "tmdb-api-key" +description = "TMDB API key" +regex = '''(?i)tmdb[_\-]?api[_\-]?key['":\s=]+[a-zA-Z0-9]{32,}''' +tags = ["api-key", "tmdb"] + +[allowlist] +description = "Safe paths and placeholder values" +paths = [ + '''\.gitignore''', + '''\.gitleaks\.toml''', + '''CLAUDE\.md''', + '''api-keys\.conf\.example''', + '''\.env\.example''', +] +regexes = [ + '''your[-_]key[-_]here''', + '''placeholder''', + '''changeme''', + '''<.*?>''', + '''\$\{[A-Z_]+\}''', + '''xxxx+''', +] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52eae8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Discarr — disc scanning and encoding queue +# ffmpeg/ffprobe included for VIDEO_TS/BDMV metadata scanning +# Encoding is dispatched via SSH to a remote host (e.g. Strahl) +FROM node:20-alpine + +RUN apk add --no-cache ffmpeg openssh-client handbrake + +WORKDIR /app +COPY server.js scanner.js ./ +COPY public/ ./public/ + +EXPOSE 8603 +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd8ca74 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CircuitForge LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..742787c --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Discarr + +> Disc scanning and HEVC encoding queue for Sonarr/Radarr. + +Discarr is a lightweight web UI that scans DVD and Blu-ray directory structures (`VIDEO_TS` / `BDMV`), lets you map raw VOBs to Sonarr episodes or Radarr movies, queues HEVC encodes via ffmpeg (local or SSH to a remote host), and notifies Sonarr/Radarr on completion. + +--- + +## What it does + +| Stage | Details | +|---|---| +| **Scan** | Detect `VIDEO_TS` / `BDMV` structures and parse IFO chapters | +| **Map** | Web UI to match disc titles to Sonarr episodes or Radarr movies | +| **Encode** | Queue HEVC encodes via ffmpeg or HandBrake (local or SSH) | +| **Notify** | Call Sonarr/Radarr import on completion; optionally notify Tdarr | + +--- + +## Requirements + +- Node.js 18+ +- ffmpeg and ffprobe (for metadata scanning) +- Docker (optional — image included) + +--- + +## Install + +```bash +git clone https://git.opensourcesolarpunk.com/Circuit-Forge/discarr +cd discarr +``` + +No npm dependencies — pure Node.js built-ins only. + +### Config + +```bash +mkdir -p ~/.config/media-postprocessor +cp api-keys.conf.example ~/.config/media-postprocessor/api-keys.conf +# Edit api-keys.conf with your Sonarr/Radarr URLs and API keys +``` + +All config values can be set as environment variables (env vars override the config file). + +### Run + +```bash +node server.js +# or: PORT=8603 node server.js +``` + +Open `http://localhost:8603` in your browser. + +### Docker + +```bash +docker build -t discarr . +docker run -d \ + -p 8603:8603 \ + -v ~/.config/media-postprocessor:/root/.config/media-postprocessor:ro \ + -v ~/.local/share/discarr:/root/.local/share/discarr \ + -v /path/to/media:/media \ + discarr +``` + +--- + +## Notification hooks + +Drop the scripts from `scripts/` as custom script hooks in Sonarr/Radarr/qBittorrent: + +| Script | Trigger | +|---|---| +| `scripts/sonarr-notify.sh` | Sonarr: Settings → Connect → Custom Script (On Import, On Episode File Delete) | +| `scripts/radarr-notify.sh` | Radarr: Settings → Connect → Custom Script (On Import, On Movie File Delete) | +| `scripts/qbittorrent-notify.sh` | qBittorrent: Options → Downloads → "Run external program on torrent completion" | + +All scripts use `DISCARR_URL` env var (default: `http://127.0.0.1:8603`). + +--- + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8603` | Web UI port | +| `DISCARR_CONFIG` | `~/.config/media-postprocessor/api-keys.conf` | Config file path | +| `DISCARR_LOG` | `~/.local/share/discarr/jobs.log` | Job log path | +| `DISCARR_QUEUE` | `~/.local/share/discarr/pending-queue.json` | Pending queue path | +| `DISCARR_SETTINGS` | Same dir as queue | Runtime settings overlay | + +--- + +## License + +MIT diff --git a/api-keys.conf.example b/api-keys.conf.example new file mode 100644 index 0000000..4484af9 --- /dev/null +++ b/api-keys.conf.example @@ -0,0 +1,29 @@ +# Discarr — API keys config +# Copy to ~/.config/media-postprocessor/api-keys.conf and fill in your values. +# +# All values can also be set as environment variables (e.g. SONARR_URL=...) +# Environment variables override this file. + +# --- Sonarr --- +SONARR_URL=http://your-sonarr-host:8989/sonarr +SONARR_API_KEY=your-sonarr-api-key-here + +# --- Radarr --- +RADARR_URL=http://your-radarr-host:7878/radarr +RADARR_API_KEY=your-radarr-api-key-here + +# --- TMDB (for title matching) --- +TMDB_API_KEY=your-tmdb-api-key-here + +# --- Tdarr (optional — notify on encode completion) --- +TDARR_URL=http://your-tdarr-host:8265 +TDARR_LIBRARY_ID=your-library-id + +# --- SSH encoding host (optional — remote ffmpeg/HandBrake) --- +# If set, Discarr dispatches encodes to this host via SSH instead of running locally. +ENCODE_SSH_HOST=user@your-encode-host + +# --- Paths (optional overrides) --- +# FFMPEG_BIN=/usr/bin/ffmpeg +# FFPROBE_BIN=/usr/bin/ffprobe +# OUTPUT_BASE=/path/to/output diff --git a/package.json b/package.json new file mode 100644 index 0000000..569a9d2 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "discarr", + "version": "0.1.0", + "description": "Disc scanning and HEVC encoding queue for Sonarr/Radarr", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT", + "dependencies": {} +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0b8f3e4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,2041 @@ + + + + + +Discarr + + + + +
+ +

+ Discarr + disc → arr + + +

+ + + + + +
+
+ 1 Scan Source +
+ + +
+ + Supports multi-disk: Disk1/VIDEO_TS, Disk2/VIDEO_TS … +
+ +
+ + + + + + + + +
+
+ Encode History + +
+
No jobs yet
+
+ + +
+
+ Arr Activity + +
+
+ + +
+
+
Loading…
+
+
+ +
+ + + diff --git a/scanner.js b/scanner.js new file mode 100644 index 0000000..93ca62b --- /dev/null +++ b/scanner.js @@ -0,0 +1,722 @@ +// +// scanner.js - Discarr disk scanner +// Relative Path: ./projects/discarr/scanner.js +// +// Detects VIDEO_TS / BDMV structures, enumerates titles via ffprobe. +// Returns structured title list with duration, audio/subtitle tracks. +// + +'use strict'; + +const { execFile } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const FFPROBE = process.env.FFPROBE_BIN || 'ffprobe'; +const LSDVD = process.env.LSDVD_BIN || 'lsdvd'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function formatDuration(secs) { + const s = Math.round(secs); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`; +} + +// --------------------------------------------------------------------------- +// DVD chapter extraction from IFO files +// --------------------------------------------------------------------------- +// lsdvd reports chapter LENGTHS; we accumulate them to get start times. +// Falls back to ffprobe on the IFO if lsdvd isn't installed. +async function extractIfoChapters(videoTsPath, titleSetNum) { + const parentDir = path.dirname(videoTsPath); // dir containing VIDEO_TS/ + + // ── Strategy 1: lsdvd ────────────────────────────────────────────────── + try { + const lsdvdOut = await new Promise((resolve, reject) => { + execFile(LSDVD, ['-c', '-t', String(titleSetNum), parentDir], + { maxBuffer: 2 * 1024 * 1024 }, + (err, stdout) => err ? reject(err) : resolve(stdout)); + }); + + const chapters = []; + let accumSecs = 0; + for (const line of lsdvdOut.split('\n')) { + // e.g. " Chapter: 01, Length: 00:12:47.333, Start Cell: 01" + const m = line.match(/Chapter:\s*(\d+),\s*Length:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); + if (!m) continue; + chapters.push({ + index: parseInt(m[1], 10) - 1, + startSec: Math.round(accumSecs), + title: null, + }); + accumSecs += parseInt(m[2], 10) * 3600 + + parseInt(m[3], 10) * 60 + + parseFloat(m[4]); + } + if (chapters.length > 1) return chapters; + } catch { /* lsdvd not available or failed */ } + + // ── Strategy 2: direct binary IFO parser (PTT + PGCIT tables) ──────── + // ffprobe can't read DVD PTT chapter structure; we parse the binary directly. + // VTSI_MAT header offsets (verified against spec and empirical data): + // 0xC8 (4B BE): VTS_PTT_SRPT sector → chapter→(pgcn,pgn) map + // 0xCC (4B BE): VTS_PGCIT sector → PGC list with cell timing + // PTT_SRPT layout: uint16 nTitles, uint16 reserved, uint32 lastByte, + // then uint32 offsets[nTitles] — each offset relative to pttBase. + // Each chapter entry: uint16 pgcn + uint16 pgn (4 bytes). + // PGC fixed header ends at 0x103; program map follows immediately after color table. + // Cell playback entry: 24 bytes, bytes 4-7 = BCD-encoded H:M:S:frames. + try { + const ifoName = `VTS_${String(titleSetNum).padStart(2, '0')}_0.IFO`; + const ifoPath = path.join(videoTsPath, ifoName); + if (!fs.existsSync(ifoPath)) throw new Error('IFO not found'); + + const buf = fs.readFileSync(ifoPath); + + // ── PTT Search Table ────────────────────────────────────────────── + const pttSector = buf.readUInt32BE(0xC8); // VTS_PTT_SRPT at 0xC8 + const pttBase = pttSector * 2048; + + const nrSrpts = buf.readUInt16BE(pttBase); // nr_of_srpts + if (nrSrpts < 1) throw new Error('PTT table empty'); + + const pttLastByte = buf.readUInt32BE(pttBase + 4); // last_byte of PTT table + // title offset array starts at pttBase+8 (one uint32 per title) + const titleOffset = buf.readUInt32BE(pttBase + 8); // byte offset for title 1 within PTT + const nextOffset = nrSrpts >= 2 + ? buf.readUInt32BE(pttBase + 12) // title 2 offset + : pttLastByte + 1; + + const nChapters = Math.floor((nextOffset - titleOffset) / 4); + if (nChapters < 1) throw new Error('no chapters in PTT for title 1'); + + // Read (pgcn, pgn) pairs for each chapter of title 1 + const pttEntries = []; + for (let c = 0; c < nChapters; c++) { + const off = pttBase + titleOffset + c * 4; + const pgcn = buf.readUInt16BE(off); + const pgn = buf.readUInt16BE(off + 2); + pttEntries.push({ pgcn, pgn }); + } + + // ── Program Chain Info Table ────────────────────────────────────── + const pgcitSector = buf.readUInt32BE(0xCC); // VTS_PGCIT at 0xCC + const pgcitBase = pgcitSector * 2048; + + const bcd = b => ((b >> 4) & 0x0F) * 10 + (b & 0x0F); + + function parsePgcCells(pgcn) { + // PGCI_SRP: 8-byte entries starting at pgcitBase+8 + const srp = pgcitBase + 8 + (pgcn - 1) * 8; + const pgcStart = pgcitBase + buf.readUInt32BE(srp + 4); + + const nPrograms = buf.readUInt8(pgcStart + 0x02); + const nCells = buf.readUInt8(pgcStart + 0x03); + + // Table pointer section is PGC+0xFC–0x103 (command, prog_map, cell_pb, cell_pos). + // Any valid pointer must point AFTER the pointer section, i.e. >= 0x104. + // Some discs (especially older authoring tools) place program map data directly + // at 0xFC (overwriting the pointer section), causing pointer reads to return + // garbage values that are < 0x104. In that case fall back to fixed offsets: + // program map at 0xFC (right after 16-color table which ends at 0xFB) + // cell playback immediately follows program map + const cmdTbl = buf.readUInt16BE(pgcStart + 0xFC); + let pmOffset, cpOffset; + if (cmdTbl >= 0x104) { + // Valid command table present — use explicit pointers + const rawPm = buf.readUInt16BE(pgcStart + 0xFE); + const rawCp = buf.readUInt16BE(pgcStart + 0x100); + pmOffset = (rawPm >= 0x104 && rawPm <= 0x4000) ? rawPm : 0xFC; + cpOffset = (rawCp > pmOffset && rawCp <= 0x4000) ? rawCp : pmOffset + nPrograms; + } else { + // No command table (or garbage pointer): program map starts right at 0xFC + pmOffset = 0xFC; + cpOffset = 0xFC + nPrograms; + } + + // Program map: 1 byte per program = first cell (1-based) for that program + const firstCell = []; + for (let p = 0; p < nPrograms; p++) { + firstCell.push(buf.readUInt8(pgcStart + pmOffset + p)); + } + + // Cell playback: 24 bytes each; bytes 4-7 = BCD H:M:S:frames + const cellDur = []; + for (let ci = 0; ci < nCells; ci++) { + const cOff = pgcStart + cpOffset + ci * 24; + const h = bcd(buf.readUInt8(cOff + 4)); + const m = bcd(buf.readUInt8(cOff + 5)); + const s = bcd(buf.readUInt8(cOff + 6)); + cellDur.push(h * 3600 + m * 60 + s); + } + + return { firstCell, cellDur }; + } + + // Cache PGC data + const pgcCache = {}; + const getPgc = pgcn => { + if (!pgcCache[pgcn]) pgcCache[pgcn] = parsePgcCells(pgcn); + return pgcCache[pgcn]; + }; + + // Total duration per PGC (for accumulating across PGC boundaries) + const uniquePgcns = [...new Set(pttEntries.map(e => e.pgcn))]; + const pgcTotal = {}; + for (const pgcn of uniquePgcns) { + const { cellDur } = getPgc(pgcn); + pgcTotal[pgcn] = cellDur.reduce((a, b) => a + b, 0); + } + + // Walk chapters: accumulate absolute start time across PGC transitions + const result = []; + let absoluteBase = 0; + let activePgcn = null; + + for (let c = 0; c < pttEntries.length; c++) { + const { pgcn, pgn } = pttEntries[c]; + const { firstCell, cellDur } = getPgc(pgcn); + + if (pgcn !== activePgcn) { + if (activePgcn !== null) absoluteBase += pgcTotal[activePgcn]; + activePgcn = pgcn; + } + + const cellStart = (firstCell[pgn - 1] || 1) - 1; // 0-based + const secInPgc = cellDur.slice(0, cellStart).reduce((a, b) => a + b, 0); + + result.push({ index: c, startSec: absoluteBase + secInPgc, title: null }); + } + + // Validate: chapters must be strictly increasing. + // Discs with interleaved cell blocks (shared cells across titles, common on + // BBC/PAL multi-episode DVDs) produce garbage cpOff values, causing cell + // durations to be read from the PGC header bytes — resulting in chapters + // that oscillate between 0 and absurdly large values. + for (let i = 1; i < result.length; i++) { + if (result[i].startSec <= result[i - 1].startSec) return []; + } + + if (result.length > 1) return result; + } catch (err) { + // IFO binary parse failed — caller falls back to VOB chapters + } + + return []; // caller will keep whatever the VOB scan found +} + +// --------------------------------------------------------------------------- +// HandBrake disc scan — enumerates all DVD titles via libdvdnav +// --------------------------------------------------------------------------- +// HandBrake writes scan output to stderr even on success. +// Chapter durations are reported as milliseconds per-chapter; we accumulate +// them into start times as we parse. +async function hbScanDisc(discRoot, onProgress) { + const hbBin = process.env.HANDBRAKE_BIN || 'HandBrakeCLI'; + + const stderr = await new Promise(resolve => { + execFile(hbBin, ['--scan', '-i', discRoot, '--title', '0'], + { maxBuffer: 4 * 1024 * 1024, timeout: 60000 }, + (err, stdout, se) => resolve(se || '')); + }); + + const lines = stderr.split('\n') + .map(l => l.replace(/^\[\d+:\d+:\d+\] /, '').trim()); + + const raw = []; + let cur = null; + let audioIdx = 0; + let subIdx = 0; + + for (const line of lines) { + const newTitle = line.match(/^scan: scanning title (\d+)/); + if (newTitle) { + if (cur) raw.push(cur); + cur = { hbTitle: +newTitle[1], durationMs: 0, durationSecs: 0, + chapters: [], audioTracks: [], subtitleTracks: [], skipped: false }; + audioIdx = 0; subIdx = 0; + continue; + } + if (!cur) continue; + + if (line.includes('ignoring title')) { cur.skipped = true; continue; } + + const dur = line.match(/^scan: duration is \d+:\d+:\d+ \((\d+) ms\)/); + if (dur) { + cur.durationMs = +dur[1]; + cur.durationSecs = Math.round(+dur[1] / 1000); + continue; + } + + const chap = line.match(/^scan: chap (\d+), (\d+) ms/); + if (chap) { + const ms = +chap[2]; + const prevEnd = cur.chapters.length + ? cur.chapters[cur.chapters.length - 1]._endMs : 0; + cur.chapters.push({ + index: +chap[1] - 1, + startSec: Math.round(prevEnd / 1000), + title: null, + _endMs: prevEnd + ms, + }); + continue; + } + + // Audio: "scan: id=0x80bd, lang=English (AC3), 3cc=eng ext=0" + const audio = line.match(/^scan: id=\S+, lang=(.+) \(([^)]+)\), 3cc=(\w+)/); + if (audio) { + cur.audioTracks.push({ + index: audioIdx++, + codec: audio[2].toLowerCase().replace(/\s+/g, '_'), + channels: 2, + language: audio[3], + label: audio[1], + }); + continue; + } + + // Subtitle: "scan: id=0x20bd, lang=English (Wide Screen) [VOBSUB], 3cc=eng ext=0" + const sub = line.match(/^scan: id=\S+, lang=(.+) \[([^\]]+)\], 3cc=(\w+)/); + if (sub) { + cur.subtitleTracks.push({ index: subIdx++, language: sub[3], label: sub[1] }); + } + } + if (cur) raw.push(cur); + + return raw + .filter(t => !t.skipped && t.durationSecs >= 60) + .map(t => { + // Strip the common DVD-authoring stub: a final chapter of < 1 s marks the + // very end of the title and would create a phantom split point. + let chapters = t.chapters; + if (chapters.length) { + const last = chapters[chapters.length - 1]; + if (last._endMs - last.startSec * 1000 < 1000) chapters = chapters.slice(0, -1); + } + chapters = chapters.map(({ _endMs, ...ch }) => ch); + + onProgress?.(` HB title ${t.hbTitle}: ${formatDuration(t.durationSecs)}` + + ` · ${chapters.length} chapters · ${t.audioTracks.length} audio`); + return { + id: `hbt${t.hbTitle}`, + hbTitle: t.hbTitle, + titleSet: t.hbTitle, // kept for display/compat + duration: formatDuration(t.durationSecs), + durationSecs: t.durationSecs, + chapterCount: chapters.length, + chapters, + videoCodec: 'mpeg2video', + audioTracks: t.audioTracks, + subtitleTracks: t.subtitleTracks, + vobFiles: [], + fileSizeBytes: 0, + hbScan: true, + }; + }); +} + +// DVD disc scanner — HandBrake scan first, VTS binary parser as fallback +async function scanDvdDisc(discRoot, videoTsPath, onProgress) { + try { + const titles = await hbScanDisc(discRoot, onProgress); + if (titles.length > 0) { + onProgress?.(` HandBrake scan: ${titles.length} title(s) found`); + return titles; + } + onProgress?.(' HandBrake scan returned no valid titles — falling back to VTS parser'); + } catch (err) { + onProgress?.(` HandBrake scan failed (${err.message}) — using VTS parser`); + } + return scanVideoTs(videoTsPath, onProgress); +} + +function ffprobe(input, extraArgs = []) { + return new Promise((resolve, reject) => { + const args = [ + '-v', 'quiet', + // Larger probe window needed for MPEG2 VOBs with broken PTS timestamps + '-probesize', '100M', + '-analyzeduration', '100M', + '-print_format', 'json', + '-show_format', + '-show_streams', + '-show_chapters', + ...extraArgs, + input, + ]; + execFile(FFPROBE, args, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + if (err) return reject(err); + try { resolve(JSON.parse(stdout)); } + catch (e) { reject(e); } + }); + }); +} + +// --------------------------------------------------------------------------- +// Single video file scanner (AVI, MKV, MP4, etc.) +// --------------------------------------------------------------------------- +async function scanVideoFile(filePath, onProgress) { + onProgress?.(` Scanning file: ${path.basename(filePath)}`); + const fileSizeBytes = fs.statSync(filePath).size; + try { + const info = await ffprobe(filePath); + const duration = parseFloat(info.format?.duration || 0); + if (duration < 1) return []; + + const videoStreams = (info.streams || []).filter(s => s.codec_type === 'video'); + const audioStreams = (info.streams || []).filter(s => s.codec_type === 'audio'); + const subStreams = (info.streams || []).filter(s => s.codec_type === 'subtitle'); + const rawChapters = info.chapters || []; + + const chapterMarks = rawChapters.map(ch => ({ + index: ch.id, + startSec: Math.round(parseFloat(ch.start_time || 0)), + title: ch.tags?.title || null, + })); + + return [{ + id: 't1', + titleSet: 1, + duration: formatDuration(duration), + durationSecs: Math.round(duration), + chapterCount: chapterMarks.length, + chapters: chapterMarks, + videoCodec: videoStreams[0]?.codec_name || 'unknown', + audioTracks: audioStreams.map((s, i) => ({ + index: i, + codec: s.codec_name, + channels: s.channels || 2, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + subtitleTracks: subStreams.map((s, i) => ({ + index: i, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + file: path.basename(filePath), + fileSizeBytes, + }]; + } catch (err) { + onProgress?.(` Failed to scan ${path.basename(filePath)}: ${err.message}`); + return []; + } +} + +// --------------------------------------------------------------------------- +// Layout detection +// --------------------------------------------------------------------------- +function detectLayout(sourcePath) { + // If the path is a plain file (e.g. .avi, .mkv), treat it as a single video title + let stat; + try { stat = fs.statSync(sourcePath); } catch { return { type: 'unknown' }; } + if (stat.isFile()) return { type: 'video', filePath: sourcePath }; + + const entries = fs.readdirSync(sourcePath).map(e => ({ + name: e, + lname: e.toLowerCase(), + full: path.join(sourcePath, e), + isDir: fs.statSync(path.join(sourcePath, e)).isDirectory(), + })); + + // Multi-disk: subdirs matching Disk1/Disc1/DISK_1 etc. (explicit naming) + const diskDirs = entries.filter(e => + e.isDir && /^(disk|disc)\s*[-_]?\s*\d+$/i.test(e.name) + ).sort((a, b) => { + const na = parseInt(a.name.match(/\d+/)[0]); + const nb = parseInt(b.name.match(/\d+/)[0]); + return na - nb; + }); + + if (diskDirs.length > 0) { + return { type: 'multi-disk', diskDirs }; + } + + // Multi-disk fallback: any subdirs that contain a VIDEO_TS or BDMV structure. + // Catches scene-release naming (e.g. "ShowName.S01D01.DVD-GRP") where the + // disc number is embedded mid-name rather than being a standalone label. + const discNumOf = name => { + // Match "Disc1", "Disk2", "D01", "D1" — any D/Disc/Disk followed by digits + const m = name.match(/(?:Dis[ck]\s*|(? + e.isDir && ( + fs.existsSync(path.join(e.full, 'VIDEO_TS')) || + fs.existsSync(path.join(e.full, 'BDMV')) + ) + ).sort((a, b) => { + const na = discNumOf(a.name); + const nb = discNumOf(b.name); + if (na !== null && nb !== null && na !== nb) return na - nb; + return a.name.localeCompare(b.name); + }); + + if (contentDiskDirs.length > 1) { + return { type: 'multi-disk', diskDirs: contentDiskDirs }; + } + + // Single VIDEO_TS + const vtDir = entries.find(e => e.isDir && e.lname === 'video_ts'); + if (vtDir) return { type: 'dvd', videoTsPath: vtDir.full }; + + // Single BDMV + const bdDir = entries.find(e => e.isDir && e.lname === 'bdmv'); + if (bdDir) return { type: 'bluray', bdmvPath: path.dirname(bdDir.full) }; + + // ISO files + const isos = entries.filter(e => !e.isDir && /\.iso$/i.test(e.name)); + if (isos.length > 0) return { type: 'iso', isoFiles: isos.map(e => e.full) }; + + return { type: 'unknown' }; +} + +// --------------------------------------------------------------------------- +// VIDEO_TS scanner +// --------------------------------------------------------------------------- +async function scanVideoTs(videoTsPath, onProgress) { + const files = fs.readdirSync(videoTsPath); + + // Group content VOBs by title set number (skip _0 = menu) + const groups = {}; + for (const file of files) { + const m = file.match(/^VTS_(\d{2})_(\d+)\.VOB$/i); + if (!m) continue; + const setNum = parseInt(m[1], 10); + const partNum = parseInt(m[2], 10); + if (partNum === 0) continue; + if (!groups[setNum]) groups[setNum] = []; + groups[setNum].push({ partNum, file, fullPath: path.join(videoTsPath, file) }); + } + + const titles = []; + const setNums = Object.keys(groups).map(Number).sort((a, b) => a - b); + + for (const setNum of setNums) { + const vobs = groups[setNum].sort((a, b) => a.partNum - b.partNum); + const concatInput = 'concat:' + vobs.map(v => v.fullPath).join('|'); + const fileSizeBytes = vobs.reduce((sum, v) => { + try { return sum + fs.statSync(v.fullPath).size; } catch { return sum; } + }, 0); + + onProgress?.(` Scanning title set ${setNum}...`); + + try { + const info = await ffprobe(concatInput); + let duration = parseFloat(info.format?.duration || 0); + + // MPEG2 VOBs with broken PTS timestamps can report 0, multi-day, or + // implausibly short durations. Sanity-check using implied bitrate: + // if ffprobe says "41 seconds" but the file is 7 GB, that's ~1.4 Gbps — + // physically impossible for MPEG2 DVD (max ~10 Mbps). + // Fall back to size-based estimate at MPEG2 DVD average of ~4.5 Mbps. + const MAX_SANE_SECS = 8 * 3600; // >8h is bogus for a disc title + const MAX_SANE_BITRATE = 15 * 1024 * 1024 / 8; // 15 Mbps in bytes/sec + const impliedBitrate = duration > 0 ? fileSizeBytes / duration : Infinity; + if (duration <= 0 || duration > MAX_SANE_SECS || impliedBitrate > MAX_SANE_BITRATE) { + const estimated = fileSizeBytes / 562500; // 4.5 Mbps avg + onProgress?.(` Title set ${setNum}: ffprobe duration unreliable (${formatDuration(duration)}, ${Math.round(impliedBitrate/125000)} Mbps implied) — estimating ${formatDuration(estimated)} from file size`); + duration = estimated; + } + + // Skip only if both duration is tiny AND file is tiny — real menus are <5 MB + if (duration < 120 && fileSizeBytes < 5 * 1024 * 1024) continue; + + const videoStreams = (info.streams || []).filter(s => s.codec_type === 'video'); + const audioStreams = (info.streams || []).filter(s => s.codec_type === 'audio'); + const subStreams = (info.streams || []).filter(s => s.codec_type === 'subtitle'); + const rawChapters = info.chapters || []; + + // VOB stream chapters (from NAV packets) + let chapterMarks = rawChapters.map(ch => ({ + index: ch.id, + startSec: Math.round(parseFloat(ch.start_time || 0)), + title: ch.tags?.title || null, + })); + + // IFO chapters are more reliable for DVDs — upgrade if they give more detail + const ifoChapters = await extractIfoChapters(videoTsPath, setNum); + if (ifoChapters.length > chapterMarks.length) { + onProgress?.(` Title set ${setNum}: using ${ifoChapters.length} IFO chapters (VOB stream had ${chapterMarks.length})`); + chapterMarks = ifoChapters; + } + + titles.push({ + id: `t${setNum}`, + titleSet: setNum, + duration: formatDuration(duration), + durationSecs: Math.round(duration), + chapterCount: chapterMarks.length, + chapters: chapterMarks, + videoCodec: videoStreams[0]?.codec_name || 'mpeg2video', + audioTracks: audioStreams.map((s, i) => ({ + index: i, + codec: s.codec_name, + channels: s.channels || 2, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + subtitleTracks: subStreams.map((s, i) => ({ + index: i, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + vobFiles: vobs.map(v => v.file), + fileSizeBytes, + }); + } catch (err) { + onProgress?.(` Skipping title set ${setNum}: ${err.message}`); + } + } + + return titles; +} + +// --------------------------------------------------------------------------- +// BDMV scanner (Bluray) +// --------------------------------------------------------------------------- +async function scanBdmv(bdmvRoot, onProgress) { + onProgress?.(' Scanning Bluray structure...'); + const titles = []; + + // BDMV/STREAM/ contains .m2ts files — each is a playlist item / title + const streamDir = path.join(bdmvRoot, 'BDMV', 'STREAM'); + if (!fs.existsSync(streamDir)) { + // Try uppercase + const streamDirUpper = path.join(bdmvRoot, 'BDMV', 'stream'); + if (!fs.existsSync(streamDirUpper)) { + onProgress?.(' BDMV/STREAM not found'); + return titles; + } + } + + const resolvedStreamDir = fs.existsSync(path.join(bdmvRoot, 'BDMV', 'STREAM')) + ? path.join(bdmvRoot, 'BDMV', 'STREAM') + : path.join(bdmvRoot, 'BDMV', 'stream'); + + const m2tsFiles = fs.readdirSync(resolvedStreamDir) + .filter(f => /\.m2ts$/i.test(f)) + .sort(); + + for (const [i, file] of m2tsFiles.entries()) { + const fullPath = path.join(resolvedStreamDir, file); + const fileSizeBytes = fs.statSync(fullPath).size; + + // Skip small files (menus, trailers) — under ~100MB + if (fileSizeBytes < 100 * 1024 * 1024) continue; + + onProgress?.(` Scanning ${file}...`); + try { + const info = await ffprobe(fullPath); + const duration = parseFloat(info.format?.duration || 0); + if (duration < 120) continue; + + const audioStreams = (info.streams || []).filter(s => s.codec_type === 'audio'); + const subStreams = (info.streams || []).filter(s => s.codec_type === 'subtitle'); + + const bdChapters = info.chapters || []; + const bdChapterMarks = bdChapters.map(ch => ({ + index: ch.id, + startSec: Math.round(parseFloat(ch.start_time || 0)), + title: ch.tags?.title || null, + })); + + titles.push({ + id: `m${i}`, + titleSet: i + 1, + file, + duration: formatDuration(duration), + durationSecs: Math.round(duration), + chapterCount: bdChapters.length, + chapters: bdChapterMarks, + videoCodec: (info.streams || []).find(s => s.codec_type === 'video')?.codec_name || 'h264', + audioTracks: audioStreams.map((s, j) => ({ + index: j, + codec: s.codec_name, + channels: s.channels || 2, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + subtitleTracks: subStreams.map((s, j) => ({ + index: j, + language: s.tags?.language || 'und', + label: s.tags?.title || null, + })), + vobFiles: [file], + fileSizeBytes, + }); + } catch (err) { + onProgress?.(` Skipping ${file}: ${err.message}`); + } + } + + return titles; +} + +// --------------------------------------------------------------------------- +// Main scan entry point +// --------------------------------------------------------------------------- +async function scan(sourcePath, onProgress) { + onProgress?.(`Scanning: ${sourcePath}`); + const layout = detectLayout(sourcePath); + onProgress?.(`Layout detected: ${layout.type}`); + + const result = { + sourcePath, + layout: layout.type, + disks: [], + }; + + if (layout.type === 'multi-disk') { + for (const [diskIdx, diskDir] of layout.diskDirs.entries()) { + // Prefer an explicit disc indicator (Disc1, Disk2, D01) over the first + // digit sequence in the name — scene releases embed the year or season + // before the disc number, which would otherwise be grabbed first. + const discM = diskDir.name.match(/(?:Dis[ck]\s*|(? sum + d.titles.length, 0); + onProgress?.(`Scan complete: ${result.disks.length} disk(s), ${totalTitles} title(s)`); + + return result; +} + +module.exports = { scan, detectLayout, extractIfoChapters, hbScanDisc }; diff --git a/scripts/qbittorrent-notify.sh b/scripts/qbittorrent-notify.sh new file mode 100755 index 0000000..5313110 --- /dev/null +++ b/scripts/qbittorrent-notify.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# qbittorrent-notify.sh — Discarr disc-rip notification hook for qBittorrent +# Relative Path: ./projects/discarr/scripts/qbittorrent-notify.sh +# +# Detects VIDEO_TS / BDMV / multi-disk structures in a completed download and +# POSTs a scan job to Discarr. qBittorrent calls this on torrent completion. +# +# qBittorrent setup: +# Options → Downloads → "Run external program on torrent completion" +# Command: /path/to/qbittorrent-notify.sh "%F" +# (or set DISCARR_URL in environment and call without args using %F as $1) +# +# Environment overrides: +# DISCARR_URL — default: http://127.0.0.1:8603 +# DISCARR_LOG — log file, default: /tmp/discarr-qbit.log +# + +set -euo pipefail + +# ── Config ─────────────────────────────────────────────────────────────────── +DISCARR_URL="${DISCARR_URL:-http://127.0.0.1:8603}" +DISCARR_LOG="${DISCARR_LOG:-/tmp/discarr-qbit.log}" +TORRENT_PATH="${1:-${TORRENT_CONTENT_PATH:-}}" + +# ── Colors ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +log() { echo -e "${CYAN}[discarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +ok() { echo -e "${GREEN}[discarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +warn() { echo -e "${YELLOW}[discarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +err() { echo -e "${RED}[discarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } + +# ── Validate ────────────────────────────────────────────────────────────────── +if [[ -z "$TORRENT_PATH" ]]; then + err "Usage: $0 " + exit 1 +fi + +if [[ ! -e "$TORRENT_PATH" ]]; then + err "Path does not exist: $TORRENT_PATH" + exit 1 +fi + +log "Checking download: $TORRENT_PATH" + +# ── Disc structure detection ────────────────────────────────────────────────── +# Walk the path (and one level deep) looking for disc layouts +detect_disc_path() { + local base="$1" + + # Direct VIDEO_TS or BDMV at root + if [[ -d "$base/VIDEO_TS" ]] || [[ -d "$base/BDMV" ]]; then + echo "$base"; return 0 + fi + + # Multi-disk: Disk1/, Disc1/, DISK_1/ … containing VIDEO_TS or BDMV + local found_multi=0 + while IFS= read -r -d '' subdir; do + local dname + dname=$(basename "$subdir") + if [[ "$dname" =~ ^[Dd]is[ck][[:space:]_-]?[0-9]+$ ]]; then + if [[ -d "$subdir/VIDEO_TS" ]] || [[ -d "$subdir/BDMV" ]]; then + found_multi=1 + fi + fi + done < <(find "$base" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null) + + if [[ $found_multi -eq 1 ]]; then + echo "$base"; return 0 + fi + + # ISO files at root + local iso_count + iso_count=$(find "$base" -maxdepth 1 -name '*.iso' -o -name '*.ISO' 2>/dev/null | wc -l) + if [[ "$iso_count" -gt 0 ]]; then + echo "$base"; return 0 + fi + + return 1 +} + +DISC_PATH="" +if [[ -d "$TORRENT_PATH" ]]; then + DISC_PATH=$(detect_disc_path "$TORRENT_PATH") || true +elif [[ "$TORRENT_PATH" =~ \.[Ii][Ss][Oo]$ ]]; then + DISC_PATH=$(dirname "$TORRENT_PATH") +fi + +if [[ -z "$DISC_PATH" ]]; then + log "No disc structure detected — skipping (not a disc rip)" + exit 0 +fi + +ok "Disc structure found at: $DISC_PATH" + +# ── Notify Discarr ──────────────────────────────────────────────────────────── +log "POSTing scan job to $DISCARR_URL ..." + +RESPONSE=$(curl -s --max-time 10 \ + -X POST "$DISCARR_URL/api/scan" \ + -H 'Content-Type: application/json' \ + -d "{\"path\": $(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$DISC_PATH")}" \ + 2>&1) || { + err "Failed to contact Discarr at $DISCARR_URL" + exit 1 +} + +JOB_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true) + +if [[ -n "$JOB_ID" ]]; then + ok "Scan queued — job ID: $JOB_ID" + ok "Open Discarr to map episodes: $DISCARR_URL" +else + err "Unexpected response from Discarr: $RESPONSE" + exit 1 +fi diff --git a/scripts/radarr-notify.sh b/scripts/radarr-notify.sh new file mode 100755 index 0000000..d95ec69 --- /dev/null +++ b/scripts/radarr-notify.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# radarr-notify.sh — Discarr integration hook for Radarr custom scripts +# Relative Path: ./projects/discarr/scripts/radarr-notify.sh +# +# Radarr calls this script on various events. +# +# Radarr setup: +# Settings → Connect → Custom Script +# Path: /path/to/radarr-notify.sh +# Notification Triggers: On Import, On Movie File Delete (optional) +# +# Radarr passes event data as environment variables. Key ones used here: +# radarr_eventtype — Download, MovieFileDelete, Test, etc. +# radarr_movie_title — Movie title +# radarr_movie_year — Release year +# radarr_movie_path — Root folder path of the movie +# radarr_moviefile_path — Full path of the imported file +# radarr_moviefile_quality — Quality profile name +# +# Environment overrides: +# DISCARR_URL — default: http://127.0.0.1:8603 +# DISCARR_LOG — log file, default: /tmp/discarr-radarr.log +# + +set -euo pipefail + +DISCARR_URL="${DISCARR_URL:-http://127.0.0.1:8603}" +DISCARR_LOG="${DISCARR_LOG:-/tmp/discarr-radarr.log}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; RESET='\033[0m' + +log() { echo -e "${CYAN}[discarr/radarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +ok() { echo -e "${GREEN}[discarr/radarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +warn() { echo -e "${YELLOW}[discarr/radarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } + +EVENT="${radarr_eventtype:-}" +MOVIE="${radarr_movie_title:-unknown}" +YEAR="${radarr_movie_year:-}" +MOVIE_PATH="${radarr_movie_path:-}" +FILE_PATH="${radarr_moviefile_path:-}" +QUALITY="${radarr_moviefile_quality:-}" + +log "Event: ${EVENT} — ${MOVIE} (${YEAR})" + +case "$EVENT" in + + Test) + ok "Test event received — Discarr hook is working." + exit 0 + ;; + + Download|MovieFileImport) + ok "Movie imported: ${MOVIE} (${YEAR}) — ${FILE_PATH} [${QUALITY}]" + + # Notify Discarr of the completed import (for job tracking) + if [[ -n "$MOVIE_PATH" ]]; then + log "Notifying Discarr of import..." + curl -s --max-time 10 \ + -X POST "${DISCARR_URL}/api/notify/radarr" \ + -H 'Content-Type: application/json' \ + -d "{\"event\":\"import\",\"moviePath\":$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$MOVIE_PATH"),\"filePath\":$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${FILE_PATH:-}")}" \ + 2>/dev/null || warn "Could not reach Discarr (non-fatal)" + fi + ;; + + MovieFileDelete) + warn "Movie file deleted: ${MOVIE} (${YEAR}) — ${FILE_PATH}" + warn "Slot is now open — consider re-ripping from disc via Discarr: ${DISCARR_URL}" + ;; + + MovieDelete) + warn "Movie deleted from Radarr: ${MOVIE} (${YEAR})" + ;; + + *) + log "Unhandled event type: ${EVENT} — no action taken" + ;; +esac + +exit 0 diff --git a/scripts/sonarr-notify.sh b/scripts/sonarr-notify.sh new file mode 100755 index 0000000..6b064ca --- /dev/null +++ b/scripts/sonarr-notify.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# sonarr-notify.sh — Discarr integration hook for Sonarr custom scripts +# Relative Path: ./projects/discarr/scripts/sonarr-notify.sh +# +# Sonarr calls this script on various events. Two useful modes: +# +# ON DOWNLOAD/IMPORT: Sonarr imported a file — tell Discarr the episode +# is now on disk (useful if you encoded via Discarr and want confirmation). +# +# ON EPISODE FILE DELETE: Sonarr removed a file — optionally log it so +# you know the slot is open for a re-rip. +# +# Sonarr setup: +# Settings → Connect → Custom Script +# Path: /path/to/sonarr-notify.sh +# Notification Triggers: On Import, On Episode File Delete (optional) +# +# Sonarr passes all event data as environment variables. Key ones used here: +# sonarr_eventtype — Download, EpisodeFileDelete, Test, etc. +# sonarr_series_title — Series name +# sonarr_series_path — Root folder path of the series +# sonarr_episodefile_path — Full path of the imported file +# sonarr_episodefile_seasonnumber +# sonarr_episodefile_episodenumbers +# +# Environment overrides: +# DISCARR_URL — default: http://127.0.0.1:8603 +# DISCARR_LOG — log file, default: /tmp/discarr-sonarr.log +# + +set -euo pipefail + +DISCARR_URL="${DISCARR_URL:-http://127.0.0.1:8603}" +DISCARR_LOG="${DISCARR_LOG:-/tmp/discarr-sonarr.log}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; RESET='\033[0m' + +log() { echo -e "${CYAN}[discarr/sonarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +ok() { echo -e "${GREEN}[discarr/sonarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } +warn() { echo -e "${YELLOW}[discarr/sonarr]${RESET} $*" | tee -a "$DISCARR_LOG"; } + +EVENT="${sonarr_eventtype:-}" +SERIES="${sonarr_series_title:-unknown}" +SERIES_PATH="${sonarr_series_path:-}" +FILE_PATH="${sonarr_episodefile_path:-}" +SEASON="${sonarr_episodefile_seasonnumber:-}" +EPISODES="${sonarr_episodefile_episodenumbers:-}" + +log "Event: ${EVENT} — ${SERIES} S${SEASON}E${EPISODES}" + +case "$EVENT" in + + Test) + ok "Test event received — Discarr hook is working." + exit 0 + ;; + + Download|EpisodeFileImport) + # Sonarr imported an episode. If we can match it back to a Discarr encode + # job, mark it complete. For now, just log the confirmed import. + ok "Episode imported: S${SEASON}E${EPISODES} → ${FILE_PATH}" + + # Optional: if the file came from the Discarr output directory, trigger + # a Sonarr rescan of the series folder to make sure the library is fresh. + if [[ -n "$SERIES_PATH" ]]; then + log "Triggering Sonarr rescan of series path (via Discarr passthrough)..." + curl -s --max-time 10 \ + -X POST "${DISCARR_URL}/api/notify/sonarr" \ + -H 'Content-Type: application/json' \ + -d "{\"event\":\"import\",\"seriesPath\":$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$SERIES_PATH"),\"filePath\":$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${FILE_PATH:-}")}" \ + 2>/dev/null || warn "Could not reach Discarr (non-fatal)" + fi + ;; + + EpisodeFileDelete) + warn "Episode deleted: S${SEASON}E${EPISODES} — ${FILE_PATH}" + warn "Slot is now open — consider re-ripping from disc via Discarr: ${DISCARR_URL}" + ;; + + SeriesDelete) + warn "Series deleted from Sonarr: ${SERIES}" + ;; + + *) + log "Unhandled event type: ${EVENT} — no action taken" + ;; +esac + +exit 0 diff --git a/server.js b/server.js new file mode 100644 index 0000000..0ddbd82 --- /dev/null +++ b/server.js @@ -0,0 +1,1182 @@ +#!/usr/bin/env node +// +// server.js - Discarr web UI backend +// Relative Path: ./projects/discarr/server.js +// +// Scan VIDEO_TS/BDMV directories, map titles to episodes via web UI, +// queue HEVC encodes via ffmpeg (local or SSH), notify Sonarr/Radarr + Tdarr on completion. +// + +'use strict'; + +const http = require('http'); +const https = require('https'); +const { spawn, execFile } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); +const { scan, extractIfoChapters } = require('./scanner'); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const PORT = parseInt(process.env.PORT || '8603', 10); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const CONFIG_PATH = process.env.DISCARR_CONFIG + || path.join(os.homedir(), '.config/media-postprocessor/api-keys.conf'); +const LOG_PATH = process.env.DISCARR_LOG + || path.join(os.homedir(), '.local/share/discarr/jobs.log'); +const QUEUE_PATH = process.env.DISCARR_QUEUE + || path.join(os.homedir(), '.local/share/discarr/pending-queue.json'); +// Settings overlay: writable JSON layered on top of (possibly read-only) CONFIG_PATH +const SETTINGS_PATH = process.env.DISCARR_SETTINGS + || path.join(path.dirname(QUEUE_PATH), 'settings.json'); + +// Known keys that can be written via the settings API +const SETTINGS_KEYS = new Set([ + 'SONARR_URL','SONARR_API_KEY','TMDB_API_KEY', + 'RADARR_URL','RADARR_API_KEY', + 'TDARR_URL','TDARR_LIBRARY_ID','ENCODE_SSH_HOST', + 'FFMPEG_BIN','FFPROBE_BIN','OUTPUT_BASE', +]); + +function loadConfig() { + // Load primary config (may be read-only in Docker) + const cfg = {}; + try { + const lines = fs.readFileSync(CONFIG_PATH, 'utf8').split('\n'); + for (const line of lines) { + const m = line.match(/^([A-Z_]+)=(.+)$/); + if (m) cfg[m[1]] = m[2].trim(); + } + } catch { /* ignore — file may not exist yet */ } + // Merge writable settings overlay (overrides CONFIG_PATH values) + try { + if (fs.existsSync(SETTINGS_PATH)) { + const overlay = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')); + Object.assign(cfg, overlay); + } + } catch { /* ignore corrupt settings */ } + return cfg; +} + +function saveSettings(updates) { + ensureDir(SETTINGS_PATH); + let current = {}; + try { + if (fs.existsSync(SETTINGS_PATH)) + current = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')); + } catch { /* start fresh */ } + for (const [k, v] of Object.entries(updates)) { + if (v === '') delete current[k]; // empty string = clear the override + else current[k] = v; + } + fs.writeFileSync(SETTINGS_PATH, JSON.stringify(current, null, 2)); +} + +let cfg = loadConfig(); + +// --------------------------------------------------------------------------- +// Persistence helpers (shared pattern with recovarr) +// --------------------------------------------------------------------------- +function ensureDir(p) { + const dir = path.dirname(p); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} + +function appendJobLog(job) { + try { + ensureDir(LOG_PATH); + const record = JSON.stringify({ + id: job.id, type: job.type, status: job.status, + sourcePath: job.sourcePath, mappings: job.mappings, + createdAt: job.createdAt, finishedAt: Date.now(), + lines: job.lines, exitCode: job.exitCode, + }); + 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); + let loaded = 0; + for (const line of raw.slice(-200)) { + 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 */ } + } + if (loaded) console.log(`Loaded ${loaded} archived job(s) from log`); + } catch (err) { console.warn('Failed to read job log:', err.message); } +} + +function savePendingQueue() { + try { + ensureDir(QUEUE_PATH); + const pending = [...jobs.values()] + .filter(j => !j.archived && ['queued','running','scanning'].includes(j.status)) + .map(j => ({ id: j.id, type: j.type, sourcePath: j.sourcePath, + mappings: j.mappings, 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)) return; + let restored = 0; + for (const entry of pending) { + if (!entry.id || jobs.has(entry.id)) continue; + jobs.set(entry.id, { + ...entry, status: 'queued', + lines: ['[discarr] Re-queued after server restart'], + exitCode: null, sseClients: new Set(), + }); + restored++; + } + if (restored) console.log(`Restored ${restored} pending job(s) from queue`); + } catch (err) { console.warn('Failed to load pending queue:', err.message); } +} + +// --------------------------------------------------------------------------- +// Job model +// status: scanning | scan-done | scan-failed | queued | running | done | failed +// type: scan | encode +// --------------------------------------------------------------------------- +const jobs = new Map(); + +function createJob(type, data) { + const id = crypto.randomBytes(6).toString('hex'); + const job = { + id, type, status: type === 'scan' ? 'scanning' : 'queued', + lines: [], exitCode: null, createdAt: Date.now(), + sseClients: new Set(), + ...data, + }; + 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 (!['scan-done','scan-failed'].includes(job.status)) + job.status = exitCode === 0 ? 'done' : 'failed'; + job.finishedAt = Date.now(); + sendEvent(job, 'done', { exitCode, status: job.status }); + for (const res of job.sseClients) res.end(); + job.sseClients.clear(); + if (job.type === 'encode') appendJobLog(job); + savePendingQueue(); +} + +// --------------------------------------------------------------------------- +// Metadata lookup — Sonarr first, TMDB fallback +// --------------------------------------------------------------------------- +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(); + }); +} + +function tmdbRequest(endpoint) { + return new Promise((resolve, reject) => { + if (!cfg.TMDB_API_KEY) return reject(new Error('TMDB_API_KEY not configured')); + const url = new URL(`https://api.themoviedb.org/3/${endpoint}`); + url.searchParams.set('api_key', cfg.TMDB_API_KEY); + const req = https.get(url.toString(), { timeout: 10_000 }, (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')); }); + }); +} + +async function lookupSeries(query) { + cfg = loadConfig(); + const results = []; + + // Sonarr lookup + if (cfg.SONARR_URL && cfg.SONARR_API_KEY) { + try { + const found = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, + `series/lookup?term=${encodeURIComponent(query)}`); + for (const s of (found || []).slice(0, 8)) { + results.push({ + source: 'sonarr', + id: s.tvdbId || s.id, + sonarrId: s.id, + title: s.title, + year: s.year, + poster: s.images?.find(i => i.coverType === 'poster')?.remoteUrl || null, + seasons: s.seasons?.map(se => se.seasonNumber).filter(n => n > 0) || [], + inLibrary: !!s.path, + }); + } + } catch (err) { console.warn('Sonarr lookup failed:', err.message); } + } + + // TMDB fallback + if (results.length === 0 && cfg.TMDB_API_KEY) { + try { + const found = await tmdbRequest(`search/tv?query=${encodeURIComponent(query)}`); + for (const s of (found?.results || []).slice(0, 8)) { + results.push({ + source: 'tmdb', + id: s.id, + title: s.name, + year: s.first_air_date?.slice(0, 4), + poster: s.poster_path ? `https://image.tmdb.org/t/p/w185${s.poster_path}` : null, + seasons: [], + inLibrary: false, + }); + } + } catch (err) { console.warn('TMDB lookup failed:', err.message); } + } + + return results; +} + +async function lookupEpisodes(source, id, season) { + cfg = loadConfig(); + + if (source === 'sonarr' && cfg.SONARR_URL && cfg.SONARR_API_KEY) { + // Find series by sonarrId or tvdbId + const allSeries = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'series'); + const series = (allSeries || []).find(s => s.id === id || s.tvdbId === id); + if (!series) throw new Error('Series not found in Sonarr'); + + const episodes = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, + `episode?seriesId=${series.id}&seasonNumber=${season}`); + return (episodes || []) + .sort((a, b) => a.episodeNumber - b.episodeNumber) + .map(e => ({ + episode: e.episodeNumber, + season: e.seasonNumber, + title: e.title, + airDate: e.airDateUtc?.slice(0, 10) || null, + overview: e.overview || null, + hasFile: e.hasFile, + })); + } + + if (source === 'tmdb' && cfg.TMDB_API_KEY) { + const data = await tmdbRequest(`tv/${id}/season/${season}`); + return (data?.episodes || []).map(e => ({ + episode: e.episode_number, + season: e.season_number, + title: e.name, + airDate: e.air_date || null, + overview: e.overview || null, + hasFile: false, + })); + } + + throw new Error('No metadata source available — add SONARR_URL/SONARR_API_KEY or TMDB_API_KEY to config'); +} + +// --------------------------------------------------------------------------- +// Tdarr integration (optional post-encode hook) +// --------------------------------------------------------------------------- +async function notifyTdarr(outputPath) { + if (!cfg.TDARR_URL) return; + try { + // Tdarr v2 API: trigger a library scan on the relevant library + // If TDARR_LIBRARY_ID is set, trigger rescan of that library + // Otherwise just log a warning that the user should configure it + if (!cfg.TDARR_LIBRARY_ID) { + console.warn('[Tdarr] TDARR_URL set but TDARR_LIBRARY_ID not configured — skipping notify'); + return; + } + const url = new URL(`${cfg.TDARR_URL}/api/v2/library/source`); + const isHttps = url.protocol === 'https:'; + const lib = isHttps ? https : http; + const body = JSON.stringify({ + data: { libraryId: cfg.TDARR_LIBRARY_ID, action: 'folderScan', folder: outputPath } + }); + await new Promise((resolve, reject) => { + const options = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + timeout: 10_000, + }; + const req = lib.request(options, res => { + res.resume(); + res.on('end', resolve); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + req.write(body); + req.end(); + }); + console.log(`[Tdarr] Triggered library scan for ${outputPath}`); + } catch (err) { + console.warn('[Tdarr] Notify failed:', err.message); + } +} + +async function notifySonarr(outputPath) { + if (!cfg.SONARR_URL || !cfg.SONARR_API_KEY) return; + try { + await arrRequest('POST', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'command', + { name: 'DownloadedEpisodesScan', path: outputPath }); + console.log(`[Sonarr] Triggered scan for ${outputPath}`); + } catch (err) { + console.warn('[Sonarr] Notify failed:', err.message); + } +} + +// --------------------------------------------------------------------------- +// Encode presets +// --------------------------------------------------------------------------- +const PRESETS = { + 'hevc-nvenc': ['-c:v','hevc_nvenc','-rc','constqp','-qp','22','-preset','p4','-c:a','copy'], + 'hevc-qsv': ['-c:v','hevc_qsv','-global_quality','22','-c:a','copy'], + 'hevc-vaapi': ['-c:v','hevc_vaapi','-qp','22','-c:a','copy'], + 'x265-software': ['-c:v','libx265','-crf','22','-preset','medium','-c:a','copy'], + 'h264-nvenc': ['-c:v','h264_nvenc','-rc','constqp','-qp','22','-preset','p4','-c:a','copy'], +}; + +// HandBrakeCLI preset templates — substitution handled in buildHandbrakeCmd() +// These use {dvd_path}, {title_set}, {input}, {output} tokens (not ffmpeg args) +const HANDBRAKE_PRESETS = { + 'handbrake-hevc': '--encoder x265 --quality 22 --aencoder copy --subtitle scan --subtitle-forced', + 'handbrake-h264': '--encoder x264 --quality 22 --aencoder copy --subtitle scan --subtitle-forced', + 'handbrake-nvenc': '--encoder nvenc_h265 --quality 22 --aencoder copy', +}; + +function isHandbrakePreset(preset) { + return preset && (preset.startsWith('handbrake-') || HANDBRAKE_PRESETS[preset]); +} + +// Returns [bin, ...args] — caller spawns directly (no shell required) +function buildHandbrakeArgs(mapping) { + const { diskType, diskPath, titleSet, hbTitle, filePath, outputPath, preset, startSec, endSec } = mapping; + const hbBin = process.env.HANDBRAKE_BIN || 'HandBrakeCLI'; + const hbArgs = (HANDBRAKE_PRESETS[preset] || HANDBRAKE_PRESETS['handbrake-hevc']).split(/\s+/).filter(Boolean); + + // HB-scan titles supply hbTitle and use the disc root directly (not /VIDEO_TS subdir), + // because HandBrake resolves VIDEO_TS internally from the parent path. + const input = hbTitle ? diskPath : (diskType === 'dvd' ? `${diskPath}/VIDEO_TS` : filePath); + const titleNum = hbTitle ?? (diskType === 'dvd' ? titleSet : null); + const args = ['--input', input, '--output', outputPath]; + + if (titleNum) args.push('--title', String(titleNum)); + if (startSec > 0) args.push('--start-at', `duration:${startSec}`); + if (endSec > 0) args.push('--stop-at', `duration:${endSec - (startSec || 0)}`); + + args.push(...hbArgs); + return [hbBin, args]; +} + +// Build the ffmpeg input args for a DVD title set +// concatVobs: array of full VOB paths +function buildDvdInput(vobPaths) { + return ['-i', 'concat:' + vobPaths.join('|')]; +} + +// Build ffmpeg args for a single file (BDMV .m2ts) +function buildFileInput(filePath) { + return ['-i', filePath]; +} + +// Run a single encode mapping on this host +async function runLocalEncode(job, mapping) { + cfg = loadConfig(); + const { diskPath, diskType, vobPaths, filePath, + outputPath, preset, customScript, audioTrack, subTrack } = mapping; + + // Ensure output directory exists + const outDir = path.dirname(outputPath); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + + // HB-scan titles have empty vobPaths — ffmpeg concat can't address them. + // Treat titleSet as the HB title number and force HandBrake. + if (!isHandbrakePreset(preset) && !customScript && diskType === 'dvd' + && !vobPaths?.length && !filePath) { + pushLine(job, `[encode] No VOB paths — routing to HandBrake (title ${mapping.hbTitle ?? mapping.titleSet})`); + mapping = { ...mapping, hbTitle: mapping.hbTitle ?? mapping.titleSet, + preset: 'handbrake-hevc' }; + } + + if (isHandbrakePreset(mapping.preset ?? preset)) { + const [hbBin, hbArgs] = buildHandbrakeArgs(mapping); + pushLine(job, `[encode] HandBrake: ${hbBin} ${hbArgs.join(' ')}`); + return new Promise((resolve, reject) => { + const proc = spawn(hbBin, hbArgs, { stdio: ['ignore','pipe','pipe'] }); + job.activeProc = proc; + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => { + const line = d.toString().trim(); + if (line.match(/Encoding|fps|ETA|error/i)) pushLine(job, line); + }); + proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`HandBrake exit ${code}`)); }); + proc.on('error', reject); + }); + } + + if (customScript) { + // Custom script: substitute {input}, {output}, {dvd_path}, {title_set} + const inputArg = diskType === 'dvd' ? vobPaths[0] : filePath; + const dvdPath = diskPath ? `${diskPath}/VIDEO_TS` : (vobPaths[0] ? path.dirname(vobPaths[0]) : ''); + const cmd = customScript + .replace(/\{input\}/g, inputArg) + .replace(/\{dvd_path\}/g, dvdPath) + .replace(/\{title_set\}/g, String(mapping.titleSet || 1)) + .replace(/\{output\}/g, outputPath); + pushLine(job, `[encode] Custom script: ${cmd}`); + return new Promise((resolve, reject) => { + const proc = spawn('sh', ['-c', cmd], { stdio: ['ignore','pipe','pipe'] }); + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => pushLine(job, d.toString().trim())); + proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Exit ${code}`))); + proc.on('error', reject); + }); + } + + const presetArgs = PRESETS[preset] || PRESETS['x265-software']; + const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg'; + + const inputArgs = diskType === 'dvd' + ? buildDvdInput(vobPaths) + : buildFileInput(filePath); + + // Audio/subtitle track mapping + const trackArgs = []; + if (audioTrack >= 0) { + trackArgs.push('-map', '0:v:0', '-map', `0:a:${audioTrack}`); + } else { + trackArgs.push('-map', '0:v:0', '-map', '0:a'); + } + if (subTrack >= 0) { + trackArgs.push('-map', `0:s:${subTrack}`); + } + + // Segment trimming: + // - DVD (concat: input): input-side -ss fast-seek cannot cross VOB file boundaries. + // Use output-side -ss instead — slower (decodes and discards), but always frame-accurate. + // - Other sources: input-side -ss is safe and fast. + const preInputArgs = []; + const postInputArgs = []; + if (diskType === 'dvd') { + if (mapping.startSec > 0) postInputArgs.push('-ss', String(mapping.startSec)); + } else { + if (mapping.startSec > 0) preInputArgs.push('-ss', String(mapping.startSec)); + } + if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0))); + + // For DVD sources, pcm_dvd audio cannot be stream-copied into MKV — transcode to AAC. + const audioOverride = diskType === 'dvd' ? ['-c:a', 'aac', '-b:a', '192k'] : []; + + const args = ['-y', ...preInputArgs, ...inputArgs, ...trackArgs, + ...postInputArgs, ...presetArgs, ...audioOverride, outputPath]; + pushLine(job, `[encode] ffmpeg ${args.join(' ')}`); + + return new Promise((resolve, reject) => { + const proc = spawn(ffmpegBin, args, { stdio: ['ignore','pipe','pipe'] }); + job.activeProc = proc; + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => { + const line = d.toString().trim(); + // Only surface progress lines and errors (ffmpeg is very verbose) + if (line.match(/frame=|time=|speed=|Error|error/i)) pushLine(job, line); + }); + proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)); }); + proc.on('error', reject); + }); +} + +// Run encode on remote host via SSH +async function runSshEncode(job, mapping, sshHost) { + cfg = loadConfig(); + const { diskPath, diskType, vobPaths, filePath, + outputPath, preset, customScript, audioTrack, subTrack } = mapping; + + const outDir = path.dirname(outputPath); + + if (!isHandbrakePreset(preset) && !customScript && diskType === 'dvd' + && !vobPaths?.length && !filePath) { + pushLine(job, `[encode] No VOB paths — routing to HandBrake (title ${mapping.hbTitle ?? mapping.titleSet})`); + mapping = { ...mapping, hbTitle: mapping.hbTitle ?? mapping.titleSet, + preset: 'handbrake-hevc' }; + } + + if (isHandbrakePreset(mapping.preset ?? preset)) { + const [hbBin, hbArgs] = buildHandbrakeArgs(mapping); + const cmd = `${hbBin} ${hbArgs.map(a => `"${a.replace(/"/g,'\\"')}"`).join(' ')}`; + const sshCmd = `mkdir -p "${outDir}" && ${cmd}`; + pushLine(job, `[encode] SSH ${sshHost} HandBrake: ${cmd}`); + return new Promise((resolve, reject) => { + const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] }); + job.activeProc = proc; + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => { + const line = d.toString().trim(); + if (line.match(/Encoding|fps|ETA|error/i)) pushLine(job, line); + }); + proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`HandBrake SSH exit ${code}`)); }); + proc.on('error', reject); + }); + } + + if (customScript) { + const inputArg = diskType === 'dvd' ? vobPaths[0] : filePath; + const dvdPath = diskPath ? `${diskPath}/VIDEO_TS` : (vobPaths[0] ? path.dirname(vobPaths[0]) : ''); + const cmd = customScript + .replace(/\{input\}/g, inputArg) + .replace(/\{dvd_path\}/g, dvdPath) + .replace(/\{title_set\}/g, String(mapping.titleSet || 1)) + .replace(/\{output\}/g, outputPath); + const sshCmd = `mkdir -p "${outDir}" && ${cmd}`; + pushLine(job, `[encode] SSH ${sshHost}: ${sshCmd}`); + return new Promise((resolve, reject) => { + const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] }); + job.activeProc = proc; + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => pushLine(job, d.toString().trim())); + proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`Exit ${code}`)); }); + proc.on('error', reject); + }); + } + + const presetArgs = PRESETS[preset] || PRESETS['x265-software']; + const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg'; + + const concatInput = diskType === 'dvd' + ? 'concat:' + vobPaths.join('|') + : filePath; + + const trackArgs = []; + if (audioTrack >= 0) { + trackArgs.push('-map', '0:v:0', '-map', `0:a:${audioTrack}`); + } else { + trackArgs.push('-map', '0:v:0', '-map', '0:a'); + } + if (subTrack >= 0) trackArgs.push('-map', `0:s:${subTrack}`); + + const preInputArgs = []; + const postInputArgs = []; + if (diskType === 'dvd') { + if (mapping.startSec > 0) postInputArgs.push('-ss', String(mapping.startSec)); + } else { + if (mapping.startSec > 0) preInputArgs.push('-ss', String(mapping.startSec)); + } + if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0))); + + const audioOverride = diskType === 'dvd' ? ['-c:a', 'aac', '-b:a', '192k'] : []; + + const ffmpegArgs = [...preInputArgs, '-y', '-i', concatInput, + ...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath] + .map(a => `"${a.replace(/"/g,'\\\"')}"`) + .join(' '); + + const sshCmd = `mkdir -p "${outDir}" && ${ffmpegBin} ${ffmpegArgs}`; + pushLine(job, `[encode] SSH ${sshHost}: ${ffmpegBin} ...`); + + return new Promise((resolve, reject) => { + const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] }); + job.activeProc = proc; + proc.stdout.on('data', d => pushLine(job, d.toString().trim())); + proc.stderr.on('data', d => { + const line = d.toString().trim(); + if (line.match(/frame=|time=|speed=|Error|error/i)) pushLine(job, line); + }); + proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`SSH encode exit ${code}`)); }); + proc.on('error', reject); + }); +} + +// --------------------------------------------------------------------------- +// Encode job runner +// --------------------------------------------------------------------------- +async function runEncodeJob(job) { + job.status = 'running'; + job.startedAt = Date.now(); + job.progress = { current: 0, total: job.mappings.length, currentFile: null, failed: 0 }; + job.cancelled = false; + job.activeProc = null; + sendEvent(job, 'status', { status: 'running' }); + cfg = loadConfig(); + + const sshHost = cfg.ENCODE_SSH_HOST || null; + let failed = 0; + + for (const [i, mapping] of job.mappings.entries()) { + if (job.cancelled) break; + const file = path.basename(mapping.outputPath); + job.progress.current = i + 1; + job.progress.currentFile = file; + sendEvent(job, 'progress', { ...job.progress }); + pushLine(job, `[encode] (${i + 1}/${job.mappings.length}) → ${mapping.outputPath}`); + try { + if (sshHost) { + await runSshEncode(job, mapping, sshHost); + } else { + await runLocalEncode(job, mapping); + } + if (job.cancelled) break; + pushLine(job, `[encode] ✓ ${file}`); + sendEvent(job, 'mapping-done', { index: i, outputPath: mapping.outputPath }); + + // Post-encode notifications (fire and forget) + const outDir = path.dirname(mapping.outputPath); + notifySonarr(outDir).catch(() => {}); + notifyTdarr(outDir).catch(() => {}); + } catch (err) { + if (job.cancelled) break; + pushLine(job, `[encode] ✗ FAILED ${file}: ${err.message}`); + failed++; + job.progress.failed = failed; + sendEvent(job, 'progress', { ...job.progress }); + } + } + + job.activeProc = null; + job.progress.currentFile = null; + if (!job.cancelled) finishJob(job, failed === 0 ? 0 : 1); +} + +// --------------------------------------------------------------------------- +// Queue — one encode at a time (scans run concurrently) +// --------------------------------------------------------------------------- +let encodeRunning = false; + +function processEncodeQueue() { + if (encodeRunning) return; + for (const job of jobs.values()) { + if (job.type === 'encode' && job.status === 'queued') { + encodeRunning = true; + runEncodeJob(job).finally(() => { + encodeRunning = false; + processEncodeQueue(); + }); + 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, type: job.type, status: job.status, + sourcePath: job.sourcePath, scanResult: job.scanResult || null, + mappings: job.mappings || null, + lineCount: job.lines.length, exitCode: job.exitCode, + createdAt: job.createdAt, finishedAt: job.finishedAt || null, + startedAt: job.startedAt || null, + archived: job.archived || false, + progress: job.progress || null, + }; +} + +// --------------------------------------------------------------------------- +// 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', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + // Static UI + if (req.method === 'GET' && p === '/') + return serveFile(res, path.join(PUBLIC_DIR, 'index.html'), 'text/html'); + + if (req.method === 'GET' && p === '/favicon.ico') { + res.writeHead(204); res.end(); return; + } + + // ── Scan ────────────────────────────────────────────────────────────── + + // POST /api/scan { path: "..." } + if (req.method === 'POST' && p === '/api/scan') { + let body; + try { body = JSON.parse(await readBody(req)); } + catch { return json(res, 400, { error: 'Invalid JSON' }); } + if (!body.path) return json(res, 400, { error: 'path required' }); + + const job = createJob('scan', { sourcePath: body.path, scanResult: null }); + json(res, 202, jobSummary(job)); + + // Run scan async — progress via SSE + scan(body.path, (line) => pushLine(job, line)) + .then(result => { + job.scanResult = result; + job.status = 'scan-done'; + job.finishedAt = Date.now(); + sendEvent(job, 'scan-done', { result }); + for (const c of job.sseClients) c.end(); + job.sseClients.clear(); + }) + .catch(err => { + job.status = 'scan-failed'; + job.finishedAt = Date.now(); + pushLine(job, `Scan error: ${err.message}`); + sendEvent(job, 'done', { error: err.message }); + for (const c of job.sseClients) c.end(); + job.sseClients.clear(); + }); + return; + } + + // ── Metadata lookup ─────────────────────────────────────────────────── + + // GET /api/lookup?q=...&type=series + if (req.method === 'GET' && p === '/api/lookup') { + const q = url.searchParams.get('q'); + const type = url.searchParams.get('type') || 'series'; + if (!q) return json(res, 400, { error: 'q required' }); + try { + const results = type === 'series' + ? await lookupSeries(q) + : []; // movie lookup — add TMDB movie search here later + return json(res, 200, results); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + // GET /api/lookup/:source/:id/episodes?season=N + const epMatch = p.match(/^\/api\/lookup\/(\w+)\/(\d+)\/episodes$/); + if (req.method === 'GET' && epMatch) { + const source = epMatch[1]; + const id = parseInt(epMatch[2], 10); + const season = parseInt(url.searchParams.get('season') || '1', 10); + try { + const episodes = await lookupEpisodes(source, id, season); + return json(res, 200, episodes); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + // GET /api/chapters?videoTsPath=...&titleSet=N + // On-demand IFO chapter extraction without a full rescan. + // Used by the split editor when chapters weren't in the original scan. + if (req.method === 'GET' && p === '/api/chapters') { + const videoTsPath = url.searchParams.get('videoTsPath'); + const titleSet = parseInt(url.searchParams.get('titleSet') || '1', 10); + if (!videoTsPath) return json(res, 400, { error: 'videoTsPath required' }); + try { + const chapters = await extractIfoChapters(videoTsPath, titleSet); + return json(res, 200, { chapters, source: chapters.length ? 'ifo' : 'none' }); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + // ── Jobs ────────────────────────────────────────────────────────────── + + // 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', + }); + + for (const line of job.lines) res.write(`data: ${JSON.stringify(line)}\n\n`); + + const active = ['scanning','queued','running'].includes(job.status); + if (!active) { + res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`); + if (job.type === 'scan' && job.scanResult) + res.write(`event: scan-done\ndata: ${JSON.stringify({ result: job.scanResult })}\n\n`); + res.end(); + return; + } + + job.sseClients.add(res); + req.on('close', () => job.sseClients.delete(res)); + return; + } + + // POST /api/encode { scanJobId, mappings: [...], preset, encodeHost } + if (req.method === 'POST' && p === '/api/encode') { + let body; + try { body = JSON.parse(await readBody(req)); } + catch { return json(res, 400, { error: 'Invalid JSON' }); } + + if (!body.mappings?.length) return json(res, 400, { error: 'mappings required' }); + + const job = createJob('encode', { + sourcePath: body.sourcePath || '', + mappings: body.mappings, + preset: body.preset || 'x265-software', + }); + processEncodeQueue(); + return json(res, 202, jobSummary(job)); + } + + // 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' && !force) + return json(res, 409, { error: 'Job is running — use ?force=true to cancel' }); + + // Cancel in-flight encode: set flag so the loop exits, kill the active process + job.cancelled = true; + if (job.activeProc) { + try { job.activeProc.kill('SIGTERM'); } catch {} + } + // If this was the running encode, unblock the queue + if (job.status === 'running') { + encodeRunning = false; + processEncodeQueue(); + } + + jobs.delete(delMatch[1]); + savePendingQueue(); + return json(res, 200, { ok: true }); + } + + // GET /api/config — capability probe for the UI + if (req.method === 'GET' && p === '/api/config') { + cfg = loadConfig(); + return json(res, 200, { + sonarr: !!(cfg.SONARR_URL && cfg.SONARR_API_KEY), + radarr: !!(cfg.RADARR_URL && cfg.RADARR_API_KEY), + tmdb: !!cfg.TMDB_API_KEY, + tdarr: !!(cfg.TDARR_URL && cfg.TDARR_LIBRARY_ID), + encodeHost: cfg.ENCODE_SSH_HOST || null, + outputBase: cfg.OUTPUT_BASE || null, + presets: [...Object.keys(PRESETS), ...Object.keys(HANDBRAKE_PRESETS)], + }); + } + + // GET /api/activity?source=sonarr|radarr — queue + recent history + if (req.method === 'GET' && p === '/api/activity') { + cfg = loadConfig(); + const source = url.searchParams.get('source') || 'sonarr'; + + const isRadarr = source === 'radarr'; + const baseUrl = isRadarr ? cfg.RADARR_URL : cfg.SONARR_URL; + const apiKey = isRadarr ? cfg.RADARR_API_KEY : cfg.SONARR_API_KEY; + + if (!baseUrl || !apiKey) + return json(res, 400, { error: `${source} not configured` }); + + try { + const includeParam = isRadarr ? 'includeMovie=true' : 'includeSeries=true&includeEpisode=true'; + + // Request more history than we need so we can filter out 'grabbed' + // events (those are already visible in the queue) and still surface + // meaningful terminal events (imported, failed, deleted, renamed). + const [rawQueue, rawHistory] = await Promise.all([ + arrRequest('GET', baseUrl, apiKey, + `queue?page=1&pageSize=200&${includeParam}&includeUnknownSeriesItems=true`), + arrRequest('GET', baseUrl, apiKey, + `history?page=1&pageSize=100&sortKey=date&sortDirection=descending&${includeParam}`), + ]); + + // Normalize queue items + const queue = ((rawQueue?.records || rawQueue || [])).map(item => { + const title = isRadarr ? item.movie?.title : item.series?.title; + const year = isRadarr ? item.movie?.year : item.series?.year; + const epInfo = isRadarr ? null : (item.episode + ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` + : null); + const pct = item.size > 0 + ? Math.round((1 - item.sizeleft / item.size) * 100) + : null; + + // Show Scan button for any completed download — if it's not a disc + // structure, the scanner will say so gracefully. + const dlState = item.trackedDownloadState || ''; + const isComplete = pct === 100 || item.status === 'completed' + || item.sizeleft === 0 || dlState === 'ignored'; + const discReady = isComplete; + + return { + id: item.id, + title, + year, + epInfo, + epTitle: item.episode?.title || null, + release: item.title, + status: item.status, + trackStatus: item.trackedDownloadStatus || 'ok', + dlState, + quality: item.quality?.quality?.name || null, + client: item.downloadClient || null, + pct, + size: item.size, + sizeleft: item.sizeleft, + outputPath: item.outputPath || null, + discReady, + }; + }); + + // Normalize history items — skip 'grabbed' events since those are + // already visible as active queue items; only surface terminal events. + const history = ((rawHistory?.records || rawHistory || [])) + .filter(item => item.eventType !== 'grabbed') + .slice(0, 30) + .map(item => { + const title = isRadarr ? item.movie?.title : item.series?.title; + const year = isRadarr ? item.movie?.year : item.series?.year; + const epInfo = isRadarr ? null : (item.episode + ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` + : null); + return { + id: item.id, + title, + year, + epInfo, + epTitle: item.episode?.title || null, + release: item.sourceTitle, + eventType: item.eventType, + quality: item.quality?.quality?.name || null, + client: item.data?.downloadClient || null, + date: item.date, + }; + }); + + return json(res, 200, { queue, history }); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + // DELETE /api/activity/:id?source=sonarr|radarr + // Removes the queue item from arr tracking WITHOUT touching the download client. + // Torrent keeps seeding; arr stops watching it. Use after Discarr encode is imported. + const actDelMatch = p.match(/^\/api\/activity\/(\d+)$/); + if (req.method === 'DELETE' && actDelMatch) { + cfg = loadConfig(); + const queueId = actDelMatch[1]; + const source = url.searchParams.get('source') || 'sonarr'; + const isRadarr = source === 'radarr'; + const baseUrl = isRadarr ? cfg.RADARR_URL : cfg.SONARR_URL; + const apiKey = isRadarr ? cfg.RADARR_API_KEY : cfg.SONARR_API_KEY; + if (!baseUrl || !apiKey) + return json(res, 400, { error: `${source} not configured` }); + try { + // blacklist=false → don't block future grabs + // removeFromClient=false → keep seeding in qBittorrent + await arrRequest('DELETE', baseUrl, apiKey, + `queue/${queueId}?blacklist=false&removeFromClient=false&skipRedownload=true`); + return json(res, 200, { ok: true }); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + // GET /api/library?source=sonarr|radarr — full library browse + if (req.method === 'GET' && p === '/api/library') { + cfg = loadConfig(); + const source = url.searchParams.get('source') || 'sonarr'; + + if (source === 'sonarr') { + if (!cfg.SONARR_URL || !cfg.SONARR_API_KEY) + return json(res, 400, { error: 'Sonarr not configured' }); + try { + const series = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'series'); + const items = (series || []).map(s => { + const total = s.statistics?.episodeCount || 0; + const onDisk = s.statistics?.episodeFileCount || 0; + return { + source: 'sonarr', + id: s.tvdbId || s.id, + sonarrId: s.id, + title: s.title, + year: s.year, + poster: s.images?.find(i => i.coverType === 'poster')?.remoteUrl || null, + seasons: (s.seasons || []).map(se => se.seasonNumber).filter(n => n > 0), + inLibrary: true, + missing: total - onDisk, + total, + onDisk, + }; + }).sort((a, b) => b.missing - a.missing); + return json(res, 200, items); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + if (source === 'radarr') { + if (!cfg.RADARR_URL || !cfg.RADARR_API_KEY) + return json(res, 400, { error: 'Radarr not configured' }); + try { + const movies = await arrRequest('GET', cfg.RADARR_URL, cfg.RADARR_API_KEY, 'movie'); + const items = (movies || []).map(m => ({ + source: 'radarr', + id: m.tmdbId || m.id, + radarrId: m.id, + title: m.title, + year: m.year, + poster: m.images?.find(i => i.coverType === 'poster')?.remoteUrl || null, + seasons: [], + inLibrary: true, + missing: m.hasFile ? 0 : 1, + hasFile: m.hasFile, + monitored: m.monitored, + })).sort((a, b) => b.missing - a.missing); + return json(res, 200, items); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + return json(res, 400, { error: 'source must be sonarr or radarr' }); + } + + // GET /api/settings — current settings for the settings panel + if (req.method === 'GET' && p === '/api/settings') { + cfg = loadConfig(); + // Return URL/non-secret values as-is; mask API keys so they're not sent to browser + const masked = k => cfg[k] ? '[configured]' : ''; + return json(res, 200, { + SONARR_URL: cfg.SONARR_URL || '', + SONARR_API_KEY: masked('SONARR_API_KEY'), + TMDB_API_KEY: masked('TMDB_API_KEY'), + TDARR_URL: cfg.TDARR_URL || '', + TDARR_LIBRARY_ID: cfg.TDARR_LIBRARY_ID || '', + ENCODE_SSH_HOST: cfg.ENCODE_SSH_HOST || '', + FFMPEG_BIN: cfg.FFMPEG_BIN || '', + FFPROBE_BIN: cfg.FFPROBE_BIN || '', + OUTPUT_BASE: cfg.OUTPUT_BASE || '', + }); + } + + // POST /api/settings — save settings overlay + if (req.method === 'POST' && p === '/api/settings') { + let body; + try { body = JSON.parse(await readBody(req)); } + catch { return json(res, 400, { error: 'Invalid JSON' }); } + + const updates = {}; + for (const [k, v] of Object.entries(body)) { + if (!SETTINGS_KEYS.has(k) || typeof v !== 'string') continue; + if (v === '[configured]') continue; // masked placeholder — skip + updates[k] = v; // empty string = clear override + } + + try { + saveSettings(updates); + cfg = loadConfig(); + return json(res, 200, { ok: true }); + } catch (err) { + return json(res, 500, { error: err.message }); + } + } + + res.writeHead(404); res.end('Not found'); +}); + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- +loadJobLog(); +loadPendingQueue(); +processEncodeQueue(); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`Discarr listening on http://0.0.0.0:${PORT}`); + console.log(`Config: ${CONFIG_PATH}`); + console.log(`Job log: ${LOG_PATH}`); + console.log(`Queue: ${QUEUE_PATH}`); + if (!fs.existsSync(CONFIG_PATH)) console.warn('WARNING: config not found'); +});