feat: initial public release — disc scanning and HEVC encode queue for Sonarr/Radarr

This commit is contained in:
pyr0ball 2026-05-26 15:19:12 -07:00
commit c8ea76292f
13 changed files with 4462 additions and 0 deletions

14
.gitignore vendored Normal file
View file

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

37
.gitleaks.toml Normal file
View file

@ -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+''',
]

13
Dockerfile Normal file
View file

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

21
LICENSE Normal file
View file

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

98
README.md Normal file
View file

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

29
api-keys.conf.example Normal file
View file

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

14
package.json Normal file
View file

@ -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": {}
}

2041
public/index.html Normal file

File diff suppressed because it is too large Load diff

722
scanner.js Normal file
View file

@ -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+0xFC0x103 (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*|(?<![a-zA-Z])D)(\d+)\b/i);
return m ? parseInt(m[1]) : null;
};
const contentDiskDirs = entries.filter(e =>
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*|(?<![a-zA-Z])D)(\d+)\b/i);
const diskNum = discM ? parseInt(discM[1]) : (diskIdx + 1);
onProgress?.(`Disk ${diskNum}: ${diskDir.full}`);
const diskLayout = detectLayout(diskDir.full);
let titles = [];
if (diskLayout.type === 'dvd') {
onProgress?.(` Type: DVD`);
titles = await scanDvdDisc(diskDir.full, diskLayout.videoTsPath, onProgress);
} else if (diskLayout.type === 'bluray') {
onProgress?.(` Type: Bluray`);
titles = await scanBdmv(diskLayout.bdmvPath, onProgress);
} else {
onProgress?.(` Unknown disk type in ${diskDir.full}`);
}
result.disks.push({ diskNum, path: diskDir.full, type: diskLayout.type, titles });
}
} else if (layout.type === 'dvd') {
const titles = await scanDvdDisc(sourcePath, layout.videoTsPath, onProgress);
result.disks.push({ diskNum: 1, path: sourcePath, type: 'dvd', titles });
} else if (layout.type === 'bluray') {
const titles = await scanBdmv(layout.bdmvPath, onProgress);
result.disks.push({ diskNum: 1, path: sourcePath, type: 'bluray', titles });
} else if (layout.type === 'video') {
const titles = await scanVideoFile(layout.filePath, onProgress);
// Use the file's parent directory as diskPath so encode mapping can find the file
result.disks.push({ diskNum: 1, path: path.dirname(layout.filePath), type: 'video', titles });
} else if (layout.type === 'iso') {
onProgress?.('ISO files detected — mount manually and re-scan the mount point');
result.error = 'ISO mounting not supported automatically — mount the ISO and point to the mount path';
} else {
onProgress?.('No VIDEO_TS or BDMV found at path');
result.error = 'No recognizable disc structure found (expected VIDEO_TS/ or BDMV/ directory)';
}
const totalTitles = result.disks.reduce((sum, d) => sum + d.titles.length, 0);
onProgress?.(`Scan complete: ${result.disks.length} disk(s), ${totalTitles} title(s)`);
return result;
}
module.exports = { scan, detectLayout, extractIfoChapters, hbScanDisc };

118
scripts/qbittorrent-notify.sh Executable file
View file

@ -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 <torrent-content-path>"
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

82
scripts/radarr-notify.sh Executable file
View file

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

91
scripts/sonarr-notify.sh Executable file
View file

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

1182
server.js Normal file

File diff suppressed because it is too large Load diff