discarr/scanner.js

722 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 };