feat: initial public release — disc scanning and HEVC encode queue for Sonarr/Radarr
This commit is contained in:
commit
c8ea76292f
13 changed files with 4462 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
37
.gitleaks.toml
Normal 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
13
Dockerfile
Normal 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
21
LICENSE
Normal 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
98
README.md
Normal 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
29
api-keys.conf.example
Normal 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
14
package.json
Normal 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
2041
public/index.html
Normal file
File diff suppressed because it is too large
Load diff
722
scanner.js
Normal file
722
scanner.js
Normal 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+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*|(?<![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
118
scripts/qbittorrent-notify.sh
Executable 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
82
scripts/radarr-notify.sh
Executable 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
91
scripts/sonarr-notify.sh
Executable 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
|
||||||
Loading…
Reference in a new issue