discarr/server.js
pyr0ball c9d6d97f80 feat: remux preset, configurable output profiles, GUI custom preset editor
- Add 'remux' preset (-c copy) for lossless stream-copy output
- DVD remux uses FLAC audio (pcm_dvd can't stream-copy into MKV)
- PRESET_<name>=<ffmpeg args> config keys add user-defined presets
- Settings panel: dynamic custom preset rows with add/remove; presets
  persist to settings overlay and appear in encode dropdown on save
- GET /api/settings returns existing PRESET_* keys; POST allowlist
  accepts PRESET_[A-Z0-9_]+ pattern alongside existing SETTINGS_KEYS
2026-05-27 13:02:29 -07:00

1211 lines
50 KiB
JavaScript

#!/usr/bin/env node
//
// server.js - Discarr web UI backend
// Relative Path: ./projects/discarr/server.js
//
// Scan VIDEO_TS/BDMV directories, map titles to episodes via web UI,
// queue HEVC encodes via ffmpeg (local or SSH), notify Sonarr/Radarr + Tdarr on completion.
//
'use strict';
const http = require('http');
const https = require('https');
const { spawn, execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { scan, extractIfoChapters } = require('./scanner');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const PORT = parseInt(process.env.PORT || '8603', 10);
const PUBLIC_DIR = path.join(__dirname, 'public');
const CONFIG_PATH = process.env.DISCARR_CONFIG
|| path.join(os.homedir(), '.config/media-postprocessor/api-keys.conf');
const LOG_PATH = process.env.DISCARR_LOG
|| path.join(os.homedir(), '.local/share/discarr/jobs.log');
const QUEUE_PATH = process.env.DISCARR_QUEUE
|| path.join(os.homedir(), '.local/share/discarr/pending-queue.json');
// Settings overlay: writable JSON layered on top of (possibly read-only) CONFIG_PATH
const SETTINGS_PATH = process.env.DISCARR_SETTINGS
|| path.join(path.dirname(QUEUE_PATH), 'settings.json');
// Known keys that can be written via the settings API
const SETTINGS_KEYS = new Set([
'SONARR_URL','SONARR_API_KEY','TMDB_API_KEY',
'RADARR_URL','RADARR_API_KEY',
'TDARR_URL','TDARR_LIBRARY_ID','ENCODE_SSH_HOST',
'FFMPEG_BIN','FFPROBE_BIN','OUTPUT_BASE',
]);
const PRESET_KEY_RE = /^PRESET_[A-Z0-9_]+$/;
function loadConfig() {
// Load primary config (may be read-only in Docker)
const cfg = {};
try {
const lines = fs.readFileSync(CONFIG_PATH, 'utf8').split('\n');
for (const line of lines) {
const m = line.match(/^([A-Z_]+)=(.+)$/);
if (m) cfg[m[1]] = m[2].trim();
}
} catch { /* ignore — file may not exist yet */ }
// Merge writable settings overlay (overrides CONFIG_PATH values)
try {
if (fs.existsSync(SETTINGS_PATH)) {
const overlay = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
Object.assign(cfg, overlay);
}
} catch { /* ignore corrupt settings */ }
return cfg;
}
function saveSettings(updates) {
ensureDir(SETTINGS_PATH);
let current = {};
try {
if (fs.existsSync(SETTINGS_PATH))
current = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
} catch { /* start fresh */ }
for (const [k, v] of Object.entries(updates)) {
if (v === '') delete current[k]; // empty string = clear the override
else current[k] = v;
}
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(current, null, 2));
}
let cfg = loadConfig();
// ---------------------------------------------------------------------------
// Persistence helpers (shared pattern with recovarr)
// ---------------------------------------------------------------------------
function ensureDir(p) {
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function appendJobLog(job) {
try {
ensureDir(LOG_PATH);
const record = JSON.stringify({
id: job.id, type: job.type, status: job.status,
sourcePath: job.sourcePath, mappings: job.mappings,
createdAt: job.createdAt, finishedAt: Date.now(),
lines: job.lines, exitCode: job.exitCode,
});
fs.appendFileSync(LOG_PATH, record + '\n');
} catch (err) { console.warn('Failed to write job log:', err.message); }
}
function loadJobLog() {
try {
if (!fs.existsSync(LOG_PATH)) return;
const raw = fs.readFileSync(LOG_PATH, 'utf8').split('\n').filter(Boolean);
let loaded = 0;
for (const line of raw.slice(-200)) {
try {
const r = JSON.parse(line);
if (!r.id || jobs.has(r.id)) continue;
jobs.set(r.id, { ...r, archived: true, sseClients: new Set() });
loaded++;
} catch { /* skip */ }
}
if (loaded) console.log(`Loaded ${loaded} archived job(s) from log`);
} catch (err) { console.warn('Failed to read job log:', err.message); }
}
function savePendingQueue() {
try {
ensureDir(QUEUE_PATH);
const pending = [...jobs.values()]
.filter(j => !j.archived && ['queued','running','scanning'].includes(j.status))
.map(j => ({ id: j.id, type: j.type, sourcePath: j.sourcePath,
mappings: j.mappings, createdAt: j.createdAt }));
fs.writeFileSync(QUEUE_PATH, JSON.stringify(pending, null, 2));
} catch (err) { console.warn('Failed to save pending queue:', err.message); }
}
function loadPendingQueue() {
try {
if (!fs.existsSync(QUEUE_PATH)) return;
const pending = JSON.parse(fs.readFileSync(QUEUE_PATH, 'utf8'));
if (!Array.isArray(pending)) return;
let restored = 0;
for (const entry of pending) {
if (!entry.id || jobs.has(entry.id)) continue;
jobs.set(entry.id, {
...entry, status: 'queued',
lines: ['[discarr] Re-queued after server restart'],
exitCode: null, sseClients: new Set(),
});
restored++;
}
if (restored) console.log(`Restored ${restored} pending job(s) from queue`);
} catch (err) { console.warn('Failed to load pending queue:', err.message); }
}
// ---------------------------------------------------------------------------
// Job model
// status: scanning | scan-done | scan-failed | queued | running | done | failed
// type: scan | encode
// ---------------------------------------------------------------------------
const jobs = new Map();
function createJob(type, data) {
const id = crypto.randomBytes(6).toString('hex');
const job = {
id, type, status: type === 'scan' ? 'scanning' : 'queued',
lines: [], exitCode: null, createdAt: Date.now(),
sseClients: new Set(),
...data,
};
jobs.set(id, job);
savePendingQueue();
return job;
}
function pushLine(job, line) {
job.lines.push(line);
for (const res of job.sseClients)
res.write(`data: ${JSON.stringify(line)}\n\n`);
}
function sendEvent(job, event, data) {
for (const res of job.sseClients)
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
function finishJob(job, exitCode) {
job.exitCode = exitCode;
if (!['scan-done','scan-failed'].includes(job.status))
job.status = exitCode === 0 ? 'done' : 'failed';
job.finishedAt = Date.now();
sendEvent(job, 'done', { exitCode, status: job.status });
for (const res of job.sseClients) res.end();
job.sseClients.clear();
if (job.type === 'encode') appendJobLog(job);
savePendingQueue();
}
// ---------------------------------------------------------------------------
// Metadata lookup — Sonarr first, TMDB fallback
// ---------------------------------------------------------------------------
function arrRequest(method, baseUrl, apiKey, endpoint, body) {
return new Promise((resolve, reject) => {
const url = new URL(`${baseUrl}/api/v3/${endpoint}`);
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const payload = body ? JSON.stringify(body) : null;
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + (url.search || ''),
method,
headers: {
'X-Api-Key': apiKey, 'Accept': 'application/json',
...(payload ? { 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload) } : {}),
},
timeout: 15_000,
};
const req = lib.request(options, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
catch { resolve(null); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (payload) req.write(payload);
req.end();
});
}
function tmdbRequest(endpoint) {
return new Promise((resolve, reject) => {
if (!cfg.TMDB_API_KEY) return reject(new Error('TMDB_API_KEY not configured'));
const url = new URL(`https://api.themoviedb.org/3/${endpoint}`);
url.searchParams.set('api_key', cfg.TMDB_API_KEY);
const req = https.get(url.toString(), { timeout: 10_000 }, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
catch { resolve(null); }
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
});
}
async function lookupSeries(query) {
cfg = loadConfig();
const results = [];
// Sonarr lookup
if (cfg.SONARR_URL && cfg.SONARR_API_KEY) {
try {
const found = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY,
`series/lookup?term=${encodeURIComponent(query)}`);
for (const s of (found || []).slice(0, 8)) {
results.push({
source: 'sonarr',
id: s.tvdbId || s.id,
sonarrId: s.id,
title: s.title,
year: s.year,
poster: s.images?.find(i => i.coverType === 'poster')?.remoteUrl || null,
seasons: s.seasons?.map(se => se.seasonNumber).filter(n => n > 0) || [],
inLibrary: !!s.path,
});
}
} catch (err) { console.warn('Sonarr lookup failed:', err.message); }
}
// TMDB fallback
if (results.length === 0 && cfg.TMDB_API_KEY) {
try {
const found = await tmdbRequest(`search/tv?query=${encodeURIComponent(query)}`);
for (const s of (found?.results || []).slice(0, 8)) {
results.push({
source: 'tmdb',
id: s.id,
title: s.name,
year: s.first_air_date?.slice(0, 4),
poster: s.poster_path ? `https://image.tmdb.org/t/p/w185${s.poster_path}` : null,
seasons: [],
inLibrary: false,
});
}
} catch (err) { console.warn('TMDB lookup failed:', err.message); }
}
return results;
}
async function lookupEpisodes(source, id, season) {
cfg = loadConfig();
if (source === 'sonarr' && cfg.SONARR_URL && cfg.SONARR_API_KEY) {
// Find series by sonarrId or tvdbId
const allSeries = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'series');
const series = (allSeries || []).find(s => s.id === id || s.tvdbId === id);
if (!series) throw new Error('Series not found in Sonarr');
const episodes = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY,
`episode?seriesId=${series.id}&seasonNumber=${season}`);
return (episodes || [])
.sort((a, b) => a.episodeNumber - b.episodeNumber)
.map(e => ({
episode: e.episodeNumber,
season: e.seasonNumber,
title: e.title,
airDate: e.airDateUtc?.slice(0, 10) || null,
overview: e.overview || null,
hasFile: e.hasFile,
}));
}
if (source === 'tmdb' && cfg.TMDB_API_KEY) {
const data = await tmdbRequest(`tv/${id}/season/${season}`);
return (data?.episodes || []).map(e => ({
episode: e.episode_number,
season: e.season_number,
title: e.name,
airDate: e.air_date || null,
overview: e.overview || null,
hasFile: false,
}));
}
throw new Error('No metadata source available — add SONARR_URL/SONARR_API_KEY or TMDB_API_KEY to config');
}
// ---------------------------------------------------------------------------
// Tdarr integration (optional post-encode hook)
// ---------------------------------------------------------------------------
async function notifyTdarr(outputPath) {
if (!cfg.TDARR_URL) return;
try {
// Tdarr v2 API: trigger a library scan on the relevant library
// If TDARR_LIBRARY_ID is set, trigger rescan of that library
// Otherwise just log a warning that the user should configure it
if (!cfg.TDARR_LIBRARY_ID) {
console.warn('[Tdarr] TDARR_URL set but TDARR_LIBRARY_ID not configured — skipping notify');
return;
}
const url = new URL(`${cfg.TDARR_URL}/api/v2/library/source`);
const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const body = JSON.stringify({
data: { libraryId: cfg.TDARR_LIBRARY_ID, action: 'folderScan', folder: outputPath }
});
await new Promise((resolve, reject) => {
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
timeout: 10_000,
};
const req = lib.request(options, res => {
res.resume();
res.on('end', resolve);
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(body);
req.end();
});
console.log(`[Tdarr] Triggered library scan for ${outputPath}`);
} catch (err) {
console.warn('[Tdarr] Notify failed:', err.message);
}
}
async function notifySonarr(outputPath) {
if (!cfg.SONARR_URL || !cfg.SONARR_API_KEY) return;
try {
await arrRequest('POST', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'command',
{ name: 'DownloadedEpisodesScan', path: outputPath });
console.log(`[Sonarr] Triggered scan for ${outputPath}`);
} catch (err) {
console.warn('[Sonarr] Notify failed:', err.message);
}
}
// ---------------------------------------------------------------------------
// Encode presets
// ---------------------------------------------------------------------------
const PRESETS = {
'remux': ['-c', 'copy'],
'hevc-nvenc': ['-c:v','hevc_nvenc','-rc','constqp','-qp','22','-preset','p4','-c:a','copy'],
'hevc-qsv': ['-c:v','hevc_qsv','-global_quality','22','-c:a','copy'],
'hevc-vaapi': ['-c:v','hevc_vaapi','-qp','22','-c:a','copy'],
'x265-software': ['-c:v','libx265','-crf','22','-preset','medium','-c:a','copy'],
'h264-nvenc': ['-c:v','h264_nvenc','-rc','constqp','-qp','22','-preset','p4','-c:a','copy'],
};
// Parse PRESET_<name>=<ffmpeg args> entries from config into additional presets.
// Names are lowercased and underscores converted to hyphens.
function parseCustomPresets(config) {
const custom = {};
for (const [k, v] of Object.entries(config)) {
if (k.startsWith('PRESET_') && v) {
const name = k.slice(7).toLowerCase().replace(/_/g, '-');
custom[name] = v.trim().split(/\s+/);
}
}
return custom;
}
function getEffectivePresets(config) {
return { ...PRESETS, ...parseCustomPresets(config) };
}
// HandBrakeCLI preset templates — substitution handled in buildHandbrakeCmd()
// These use {dvd_path}, {title_set}, {input}, {output} tokens (not ffmpeg args)
const HANDBRAKE_PRESETS = {
'handbrake-hevc': '--encoder x265 --quality 22 --aencoder copy --subtitle scan --subtitle-forced',
'handbrake-h264': '--encoder x264 --quality 22 --aencoder copy --subtitle scan --subtitle-forced',
'handbrake-nvenc': '--encoder nvenc_h265 --quality 22 --aencoder copy',
};
function isHandbrakePreset(preset) {
return preset && (preset.startsWith('handbrake-') || HANDBRAKE_PRESETS[preset]);
}
// Returns [bin, ...args] — caller spawns directly (no shell required)
function buildHandbrakeArgs(mapping) {
const { diskType, diskPath, titleSet, hbTitle, filePath, outputPath, preset, startSec, endSec } = mapping;
const hbBin = process.env.HANDBRAKE_BIN || 'HandBrakeCLI';
const hbArgs = (HANDBRAKE_PRESETS[preset] || HANDBRAKE_PRESETS['handbrake-hevc']).split(/\s+/).filter(Boolean);
// HB-scan titles supply hbTitle and use the disc root directly (not /VIDEO_TS subdir),
// because HandBrake resolves VIDEO_TS internally from the parent path.
const input = hbTitle ? diskPath : (diskType === 'dvd' ? `${diskPath}/VIDEO_TS` : filePath);
const titleNum = hbTitle ?? (diskType === 'dvd' ? titleSet : null);
const args = ['--input', input, '--output', outputPath];
if (titleNum) args.push('--title', String(titleNum));
if (startSec > 0) args.push('--start-at', `duration:${startSec}`);
if (endSec > 0) args.push('--stop-at', `duration:${endSec - (startSec || 0)}`);
args.push(...hbArgs);
return [hbBin, args];
}
// Build the ffmpeg input args for a DVD title set
// concatVobs: array of full VOB paths
function buildDvdInput(vobPaths) {
return ['-i', 'concat:' + vobPaths.join('|')];
}
// Build ffmpeg args for a single file (BDMV .m2ts)
function buildFileInput(filePath) {
return ['-i', filePath];
}
// Run a single encode mapping on this host
async function runLocalEncode(job, mapping) {
cfg = loadConfig();
const { diskPath, diskType, vobPaths, filePath,
outputPath, preset, customScript, audioTrack, subTrack } = mapping;
// Ensure output directory exists
const outDir = path.dirname(outputPath);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
// HB-scan titles have empty vobPaths — ffmpeg concat can't address them.
// Treat titleSet as the HB title number and force HandBrake.
if (!isHandbrakePreset(preset) && !customScript && diskType === 'dvd'
&& !vobPaths?.length && !filePath) {
pushLine(job, `[encode] No VOB paths — routing to HandBrake (title ${mapping.hbTitle ?? mapping.titleSet})`);
mapping = { ...mapping, hbTitle: mapping.hbTitle ?? mapping.titleSet,
preset: 'handbrake-hevc' };
}
if (isHandbrakePreset(mapping.preset ?? preset)) {
const [hbBin, hbArgs] = buildHandbrakeArgs(mapping);
pushLine(job, `[encode] HandBrake: ${hbBin} ${hbArgs.join(' ')}`);
return new Promise((resolve, reject) => {
const proc = spawn(hbBin, hbArgs, { stdio: ['ignore','pipe','pipe'] });
job.activeProc = proc;
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => {
const line = d.toString().trim();
if (line.match(/Encoding|fps|ETA|error/i)) pushLine(job, line);
});
proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`HandBrake exit ${code}`)); });
proc.on('error', reject);
});
}
if (customScript) {
// Custom script: substitute {input}, {output}, {dvd_path}, {title_set}
const inputArg = diskType === 'dvd' ? vobPaths[0] : filePath;
const dvdPath = diskPath ? `${diskPath}/VIDEO_TS` : (vobPaths[0] ? path.dirname(vobPaths[0]) : '');
const cmd = customScript
.replace(/\{input\}/g, inputArg)
.replace(/\{dvd_path\}/g, dvdPath)
.replace(/\{title_set\}/g, String(mapping.titleSet || 1))
.replace(/\{output\}/g, outputPath);
pushLine(job, `[encode] Custom script: ${cmd}`);
return new Promise((resolve, reject) => {
const proc = spawn('sh', ['-c', cmd], { stdio: ['ignore','pipe','pipe'] });
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => pushLine(job, d.toString().trim()));
proc.on('close', code => code === 0 ? resolve() : reject(new Error(`Exit ${code}`)));
proc.on('error', reject);
});
}
const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const inputArgs = diskType === 'dvd'
? buildDvdInput(vobPaths)
: buildFileInput(filePath);
// Audio/subtitle track mapping
const trackArgs = [];
if (audioTrack >= 0) {
trackArgs.push('-map', '0:v:0', '-map', `0:a:${audioTrack}`);
} else {
trackArgs.push('-map', '0:v:0', '-map', '0:a');
}
if (subTrack >= 0) {
trackArgs.push('-map', `0:s:${subTrack}`);
}
// Segment trimming:
// - DVD (concat: input): input-side -ss fast-seek cannot cross VOB file boundaries.
// Use output-side -ss instead — slower (decodes and discards), but always frame-accurate.
// - Other sources: input-side -ss is safe and fast.
const preInputArgs = [];
const postInputArgs = [];
if (diskType === 'dvd') {
if (mapping.startSec > 0) postInputArgs.push('-ss', String(mapping.startSec));
} else {
if (mapping.startSec > 0) preInputArgs.push('-ss', String(mapping.startSec));
}
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0)));
// pcm_dvd audio cannot be stream-copied into MKV. For remux use lossless FLAC;
// for encode presets transcode to AAC. BDMV sources need no override.
const audioOverride = diskType === 'dvd'
? (preset === 'remux' ? ['-c:a', 'flac'] : ['-c:a', 'aac', '-b:a', '192k'])
: [];
const args = ['-y', ...preInputArgs, ...inputArgs, ...trackArgs,
...postInputArgs, ...presetArgs, ...audioOverride, outputPath];
pushLine(job, `[encode] ffmpeg ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const proc = spawn(ffmpegBin, args, { stdio: ['ignore','pipe','pipe'] });
job.activeProc = proc;
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => {
const line = d.toString().trim();
// Only surface progress lines and errors (ffmpeg is very verbose)
if (line.match(/frame=|time=|speed=|Error|error/i)) pushLine(job, line);
});
proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)); });
proc.on('error', reject);
});
}
// Run encode on remote host via SSH
async function runSshEncode(job, mapping, sshHost) {
cfg = loadConfig();
const { diskPath, diskType, vobPaths, filePath,
outputPath, preset, customScript, audioTrack, subTrack } = mapping;
const outDir = path.dirname(outputPath);
if (!isHandbrakePreset(preset) && !customScript && diskType === 'dvd'
&& !vobPaths?.length && !filePath) {
pushLine(job, `[encode] No VOB paths — routing to HandBrake (title ${mapping.hbTitle ?? mapping.titleSet})`);
mapping = { ...mapping, hbTitle: mapping.hbTitle ?? mapping.titleSet,
preset: 'handbrake-hevc' };
}
if (isHandbrakePreset(mapping.preset ?? preset)) {
const [hbBin, hbArgs] = buildHandbrakeArgs(mapping);
const cmd = `${hbBin} ${hbArgs.map(a => `"${a.replace(/"/g,'\\"')}"`).join(' ')}`;
const sshCmd = `mkdir -p "${outDir}" && ${cmd}`;
pushLine(job, `[encode] SSH ${sshHost} HandBrake: ${cmd}`);
return new Promise((resolve, reject) => {
const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] });
job.activeProc = proc;
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => {
const line = d.toString().trim();
if (line.match(/Encoding|fps|ETA|error/i)) pushLine(job, line);
});
proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`HandBrake SSH exit ${code}`)); });
proc.on('error', reject);
});
}
if (customScript) {
const inputArg = diskType === 'dvd' ? vobPaths[0] : filePath;
const dvdPath = diskPath ? `${diskPath}/VIDEO_TS` : (vobPaths[0] ? path.dirname(vobPaths[0]) : '');
const cmd = customScript
.replace(/\{input\}/g, inputArg)
.replace(/\{dvd_path\}/g, dvdPath)
.replace(/\{title_set\}/g, String(mapping.titleSet || 1))
.replace(/\{output\}/g, outputPath);
const sshCmd = `mkdir -p "${outDir}" && ${cmd}`;
pushLine(job, `[encode] SSH ${sshHost}: ${sshCmd}`);
return new Promise((resolve, reject) => {
const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] });
job.activeProc = proc;
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => pushLine(job, d.toString().trim()));
proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`Exit ${code}`)); });
proc.on('error', reject);
});
}
const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const concatInput = diskType === 'dvd'
? 'concat:' + vobPaths.join('|')
: filePath;
const trackArgs = [];
if (audioTrack >= 0) {
trackArgs.push('-map', '0:v:0', '-map', `0:a:${audioTrack}`);
} else {
trackArgs.push('-map', '0:v:0', '-map', '0:a');
}
if (subTrack >= 0) trackArgs.push('-map', `0:s:${subTrack}`);
const preInputArgs = [];
const postInputArgs = [];
if (diskType === 'dvd') {
if (mapping.startSec > 0) postInputArgs.push('-ss', String(mapping.startSec));
} else {
if (mapping.startSec > 0) preInputArgs.push('-ss', String(mapping.startSec));
}
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0)));
const audioOverride = diskType === 'dvd'
? (preset === 'remux' ? ['-c:a', 'flac'] : ['-c:a', 'aac', '-b:a', '192k'])
: [];
const ffmpegArgs = [...preInputArgs, '-y', '-i', concatInput,
...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath]
.map(a => `"${a.replace(/"/g,'\\\"')}"`)
.join(' ');
const sshCmd = `mkdir -p "${outDir}" && ${ffmpegBin} ${ffmpegArgs}`;
pushLine(job, `[encode] SSH ${sshHost}: ${ffmpegBin} ...`);
return new Promise((resolve, reject) => {
const proc = spawn('ssh', [sshHost, sshCmd], { stdio: ['ignore','pipe','pipe'] });
job.activeProc = proc;
proc.stdout.on('data', d => pushLine(job, d.toString().trim()));
proc.stderr.on('data', d => {
const line = d.toString().trim();
if (line.match(/frame=|time=|speed=|Error|error/i)) pushLine(job, line);
});
proc.on('close', code => { job.activeProc = null; code === 0 ? resolve() : reject(new Error(`SSH encode exit ${code}`)); });
proc.on('error', reject);
});
}
// ---------------------------------------------------------------------------
// Encode job runner
// ---------------------------------------------------------------------------
async function runEncodeJob(job) {
job.status = 'running';
job.startedAt = Date.now();
job.progress = { current: 0, total: job.mappings.length, currentFile: null, failed: 0 };
job.cancelled = false;
job.activeProc = null;
sendEvent(job, 'status', { status: 'running' });
cfg = loadConfig();
const sshHost = cfg.ENCODE_SSH_HOST || null;
let failed = 0;
for (const [i, mapping] of job.mappings.entries()) {
if (job.cancelled) break;
const file = path.basename(mapping.outputPath);
job.progress.current = i + 1;
job.progress.currentFile = file;
sendEvent(job, 'progress', { ...job.progress });
pushLine(job, `[encode] (${i + 1}/${job.mappings.length}) → ${mapping.outputPath}`);
try {
if (sshHost) {
await runSshEncode(job, mapping, sshHost);
} else {
await runLocalEncode(job, mapping);
}
if (job.cancelled) break;
pushLine(job, `[encode] ✓ ${file}`);
sendEvent(job, 'mapping-done', { index: i, outputPath: mapping.outputPath });
// Post-encode notifications (fire and forget)
const outDir = path.dirname(mapping.outputPath);
notifySonarr(outDir).catch(() => {});
notifyTdarr(outDir).catch(() => {});
} catch (err) {
if (job.cancelled) break;
pushLine(job, `[encode] ✗ FAILED ${file}: ${err.message}`);
failed++;
job.progress.failed = failed;
sendEvent(job, 'progress', { ...job.progress });
}
}
job.activeProc = null;
job.progress.currentFile = null;
if (!job.cancelled) finishJob(job, failed === 0 ? 0 : 1);
}
// ---------------------------------------------------------------------------
// Queue — one encode at a time (scans run concurrently)
// ---------------------------------------------------------------------------
let encodeRunning = false;
function processEncodeQueue() {
if (encodeRunning) return;
for (const job of jobs.values()) {
if (job.type === 'encode' && job.status === 'queued') {
encodeRunning = true;
runEncodeJob(job).finally(() => {
encodeRunning = false;
processEncodeQueue();
});
break;
}
}
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
function serveFile(res, filePath, contentType) {
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
}
function json(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
function jobSummary(job) {
return {
id: job.id, type: job.type, status: job.status,
sourcePath: job.sourcePath, scanResult: job.scanResult || null,
mappings: job.mappings || null,
lineCount: job.lines.length, exitCode: job.exitCode,
createdAt: job.createdAt, finishedAt: job.finishedAt || null,
startedAt: job.startedAt || null,
archived: job.archived || false,
progress: job.progress || null,
};
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname.replace(/\/+$/, '') || '/';
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
// Static UI
if (req.method === 'GET' && p === '/')
return serveFile(res, path.join(PUBLIC_DIR, 'index.html'), 'text/html');
if (req.method === 'GET' && p === '/favicon.ico') {
res.writeHead(204); res.end(); return;
}
// ── Scan ──────────────────────────────────────────────────────────────
// POST /api/scan { path: "..." }
if (req.method === 'POST' && p === '/api/scan') {
let body;
try { body = JSON.parse(await readBody(req)); }
catch { return json(res, 400, { error: 'Invalid JSON' }); }
if (!body.path) return json(res, 400, { error: 'path required' });
const job = createJob('scan', { sourcePath: body.path, scanResult: null });
json(res, 202, jobSummary(job));
// Run scan async — progress via SSE
scan(body.path, (line) => pushLine(job, line))
.then(result => {
job.scanResult = result;
job.status = 'scan-done';
job.finishedAt = Date.now();
sendEvent(job, 'scan-done', { result });
for (const c of job.sseClients) c.end();
job.sseClients.clear();
})
.catch(err => {
job.status = 'scan-failed';
job.finishedAt = Date.now();
pushLine(job, `Scan error: ${err.message}`);
sendEvent(job, 'done', { error: err.message });
for (const c of job.sseClients) c.end();
job.sseClients.clear();
});
return;
}
// ── Metadata lookup ───────────────────────────────────────────────────
// GET /api/lookup?q=...&type=series
if (req.method === 'GET' && p === '/api/lookup') {
const q = url.searchParams.get('q');
const type = url.searchParams.get('type') || 'series';
if (!q) return json(res, 400, { error: 'q required' });
try {
const results = type === 'series'
? await lookupSeries(q)
: []; // movie lookup — add TMDB movie search here later
return json(res, 200, results);
} catch (err) {
return json(res, 500, { error: err.message });
}
}
// GET /api/lookup/:source/:id/episodes?season=N
const epMatch = p.match(/^\/api\/lookup\/(\w+)\/(\d+)\/episodes$/);
if (req.method === 'GET' && epMatch) {
const source = epMatch[1];
const id = parseInt(epMatch[2], 10);
const season = parseInt(url.searchParams.get('season') || '1', 10);
try {
const episodes = await lookupEpisodes(source, id, season);
return json(res, 200, episodes);
} catch (err) {
return json(res, 500, { error: err.message });
}
}
// GET /api/chapters?videoTsPath=...&titleSet=N
// On-demand IFO chapter extraction without a full rescan.
// Used by the split editor when chapters weren't in the original scan.
if (req.method === 'GET' && p === '/api/chapters') {
const videoTsPath = url.searchParams.get('videoTsPath');
const titleSet = parseInt(url.searchParams.get('titleSet') || '1', 10);
if (!videoTsPath) return json(res, 400, { error: 'videoTsPath required' });
try {
const chapters = await extractIfoChapters(videoTsPath, titleSet);
return json(res, 200, { chapters, source: chapters.length ? 'ifo' : 'none' });
} catch (err) {
return json(res, 500, { error: err.message });
}
}
// ── Jobs ──────────────────────────────────────────────────────────────
// GET /api/jobs
if (req.method === 'GET' && p === '/api/jobs')
return json(res, 200,
[...jobs.values()].sort((a, b) => b.createdAt - a.createdAt).map(jobSummary));
// GET /api/jobs/:id
const jobMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)$/);
if (req.method === 'GET' && jobMatch) {
const job = jobs.get(jobMatch[1]);
if (!job) return json(res, 404, { error: 'Not found' });
return json(res, 200, { ...jobSummary(job), lines: job.lines });
}
// GET /api/jobs/:id/stream (SSE)
const streamMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)\/stream$/);
if (req.method === 'GET' && streamMatch) {
const job = jobs.get(streamMatch[1]);
if (!job) return json(res, 404, { error: 'Not found' });
res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
'Connection': 'keep-alive', 'X-Accel-Buffering': 'no',
});
for (const line of job.lines) res.write(`data: ${JSON.stringify(line)}\n\n`);
const active = ['scanning','queued','running'].includes(job.status);
if (!active) {
res.write(`event: done\ndata: ${JSON.stringify({ status: job.status, exitCode: job.exitCode })}\n\n`);
if (job.type === 'scan' && job.scanResult)
res.write(`event: scan-done\ndata: ${JSON.stringify({ result: job.scanResult })}\n\n`);
res.end();
return;
}
job.sseClients.add(res);
req.on('close', () => job.sseClients.delete(res));
return;
}
// POST /api/encode { scanJobId, mappings: [...], preset, encodeHost }
if (req.method === 'POST' && p === '/api/encode') {
let body;
try { body = JSON.parse(await readBody(req)); }
catch { return json(res, 400, { error: 'Invalid JSON' }); }
if (!body.mappings?.length) return json(res, 400, { error: 'mappings required' });
const job = createJob('encode', {
sourcePath: body.sourcePath || '',
mappings: body.mappings,
preset: body.preset || 'x265-software',
});
processEncodeQueue();
return json(res, 202, jobSummary(job));
}
// DELETE /api/jobs/:id
const delMatch = p.match(/^\/api\/jobs\/([a-f0-9]+)$/);
if (req.method === 'DELETE' && delMatch) {
const job = jobs.get(delMatch[1]);
if (!job) return json(res, 404, { error: 'Not found' });
const force = url.searchParams.get('force') === 'true';
if (job.status === 'running' && !force)
return json(res, 409, { error: 'Job is running — use ?force=true to cancel' });
// Cancel in-flight encode: set flag so the loop exits, kill the active process
job.cancelled = true;
if (job.activeProc) {
try { job.activeProc.kill('SIGTERM'); } catch {}
}
// If this was the running encode, unblock the queue
if (job.status === 'running') {
encodeRunning = false;
processEncodeQueue();
}
jobs.delete(delMatch[1]);
savePendingQueue();
return json(res, 200, { ok: true });
}
// GET /api/config — capability probe for the UI
if (req.method === 'GET' && p === '/api/config') {
cfg = loadConfig();
return json(res, 200, {
sonarr: !!(cfg.SONARR_URL && cfg.SONARR_API_KEY),
radarr: !!(cfg.RADARR_URL && cfg.RADARR_API_KEY),
tmdb: !!cfg.TMDB_API_KEY,
tdarr: !!(cfg.TDARR_URL && cfg.TDARR_LIBRARY_ID),
encodeHost: cfg.ENCODE_SSH_HOST || null,
outputBase: cfg.OUTPUT_BASE || null,
presets: [...Object.keys(getEffectivePresets(cfg)), ...Object.keys(HANDBRAKE_PRESETS)],
});
}
// GET /api/activity?source=sonarr|radarr — queue + recent history
if (req.method === 'GET' && p === '/api/activity') {
cfg = loadConfig();
const source = url.searchParams.get('source') || 'sonarr';
const isRadarr = source === 'radarr';
const baseUrl = isRadarr ? cfg.RADARR_URL : cfg.SONARR_URL;
const apiKey = isRadarr ? cfg.RADARR_API_KEY : cfg.SONARR_API_KEY;
if (!baseUrl || !apiKey)
return json(res, 400, { error: `${source} not configured` });
try {
const includeParam = isRadarr ? 'includeMovie=true' : 'includeSeries=true&includeEpisode=true';
// Request more history than we need so we can filter out 'grabbed'
// events (those are already visible in the queue) and still surface
// meaningful terminal events (imported, failed, deleted, renamed).
const [rawQueue, rawHistory] = await Promise.all([
arrRequest('GET', baseUrl, apiKey,
`queue?page=1&pageSize=200&${includeParam}&includeUnknownSeriesItems=true`),
arrRequest('GET', baseUrl, apiKey,
`history?page=1&pageSize=100&sortKey=date&sortDirection=descending&${includeParam}`),
]);
// Normalize queue items
const queue = ((rawQueue?.records || rawQueue || [])).map(item => {
const title = isRadarr ? item.movie?.title : item.series?.title;
const year = isRadarr ? item.movie?.year : item.series?.year;
const epInfo = isRadarr ? null : (item.episode
? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}`
: null);
const pct = item.size > 0
? Math.round((1 - item.sizeleft / item.size) * 100)
: null;
// Show Scan button for any completed download — if it's not a disc
// structure, the scanner will say so gracefully.
const dlState = item.trackedDownloadState || '';
const isComplete = pct === 100 || item.status === 'completed'
|| item.sizeleft === 0 || dlState === 'ignored';
const discReady = isComplete;
return {
id: item.id,
title,
year,
epInfo,
epTitle: item.episode?.title || null,
release: item.title,
status: item.status,
trackStatus: item.trackedDownloadStatus || 'ok',
dlState,
quality: item.quality?.quality?.name || null,
client: item.downloadClient || null,
pct,
size: item.size,
sizeleft: item.sizeleft,
outputPath: item.outputPath || null,
discReady,
};
});
// Normalize history items — skip 'grabbed' events since those are
// already visible as active queue items; only surface terminal events.
const history = ((rawHistory?.records || rawHistory || []))
.filter(item => item.eventType !== 'grabbed')
.slice(0, 30)
.map(item => {
const title = isRadarr ? item.movie?.title : item.series?.title;
const year = isRadarr ? item.movie?.year : item.series?.year;
const epInfo = isRadarr ? null : (item.episode
? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}`
: null);
return {
id: item.id,
title,
year,
epInfo,
epTitle: item.episode?.title || null,
release: item.sourceTitle,
eventType: item.eventType,
quality: item.quality?.quality?.name || null,
client: item.data?.downloadClient || null,
date: item.date,
};
});
return json(res, 200, { queue, history });
} catch (err) {
return json(res, 500, { error: err.message });
}
}
// DELETE /api/activity/:id?source=sonarr|radarr
// Removes the queue item from arr tracking WITHOUT touching the download client.
// Torrent keeps seeding; arr stops watching it. Use after Discarr encode is imported.
const actDelMatch = p.match(/^\/api\/activity\/(\d+)$/);
if (req.method === 'DELETE' && actDelMatch) {
cfg = loadConfig();
const queueId = actDelMatch[1];
const source = url.searchParams.get('source') || 'sonarr';
const isRadarr = source === 'radarr';
const baseUrl = isRadarr ? cfg.RADARR_URL : cfg.SONARR_URL;
const apiKey = isRadarr ? cfg.RADARR_API_KEY : cfg.SONARR_API_KEY;
if (!baseUrl || !apiKey)
return json(res, 400, { error: `${source} not configured` });
try {
// blacklist=false → don't block future grabs
// removeFromClient=false → keep seeding in qBittorrent
await arrRequest('DELETE', baseUrl, apiKey,
`queue/${queueId}?blacklist=false&removeFromClient=false&skipRedownload=true`);
return json(res, 200, { ok: true });
} catch (err) {
return json(res, 500, { error: err.message });
}
}
// GET /api/library?source=sonarr|radarr — full library browse
if (req.method === 'GET' && p === '/api/library') {
cfg = loadConfig();
const source = url.searchParams.get('source') || 'sonarr';
if (source === 'sonarr') {
if (!cfg.SONARR_URL || !cfg.SONARR_API_KEY)
return json(res, 400, { error: 'Sonarr not configured' });
try {
const series = await arrRequest('GET', cfg.SONARR_URL, cfg.SONARR_API_KEY, 'series');
const items = (series || []).map(s => {
const total = s.statistics?.episodeCount || 0;
const onDisk = s.statistics?.episodeFileCount || 0;
return {
source: 'sonarr',
id: s.tvdbId || s.id,
sonarrId: s.id,
title: s.title,
year: s.year,
poster: s.images?.find(i => i.coverType === 'poster')?.remoteUrl || null,
seasons: (s.seasons || []).map(se => se.seasonNumber).filter(n => n > 0),
inLibrary: true,
missing: total - onDisk,
total,
onDisk,
};
}).sort((a, b) => b.missing - a.missing);
return json(res, 200, items);
} catch (err) {
return json(res, 500, { error: err.message });
}
}
if (source === 'radarr') {
if (!cfg.RADARR_URL || !cfg.RADARR_API_KEY)
return json(res, 400, { error: 'Radarr not configured' });
try {
const movies = await arrRequest('GET', cfg.RADARR_URL, cfg.RADARR_API_KEY, 'movie');
const items = (movies || []).map(m => ({
source: 'radarr',
id: m.tmdbId || m.id,
radarrId: m.id,
title: m.title,
year: m.year,
poster: m.images?.find(i => i.coverType === 'poster')?.remoteUrl || null,
seasons: [],
inLibrary: true,
missing: m.hasFile ? 0 : 1,
hasFile: m.hasFile,
monitored: m.monitored,
})).sort((a, b) => b.missing - a.missing);
return json(res, 200, items);
} catch (err) {
return json(res, 500, { error: err.message });
}
}
return json(res, 400, { error: 'source must be sonarr or radarr' });
}
// GET /api/settings — current settings for the settings panel
if (req.method === 'GET' && p === '/api/settings') {
cfg = loadConfig();
// Return URL/non-secret values as-is; mask API keys so they're not sent to browser
const masked = k => cfg[k] ? '[configured]' : '';
const presetEntries = {};
for (const [k, v] of Object.entries(cfg)) {
if (PRESET_KEY_RE.test(k)) presetEntries[k] = v;
}
return json(res, 200, {
SONARR_URL: cfg.SONARR_URL || '',
SONARR_API_KEY: masked('SONARR_API_KEY'),
TMDB_API_KEY: masked('TMDB_API_KEY'),
TDARR_URL: cfg.TDARR_URL || '',
TDARR_LIBRARY_ID: cfg.TDARR_LIBRARY_ID || '',
ENCODE_SSH_HOST: cfg.ENCODE_SSH_HOST || '',
FFMPEG_BIN: cfg.FFMPEG_BIN || '',
FFPROBE_BIN: cfg.FFPROBE_BIN || '',
OUTPUT_BASE: cfg.OUTPUT_BASE || '',
...presetEntries,
});
}
// POST /api/settings — save settings overlay
if (req.method === 'POST' && p === '/api/settings') {
let body;
try { body = JSON.parse(await readBody(req)); }
catch { return json(res, 400, { error: 'Invalid JSON' }); }
const updates = {};
for (const [k, v] of Object.entries(body)) {
if ((!SETTINGS_KEYS.has(k) && !PRESET_KEY_RE.test(k)) || typeof v !== 'string') continue;
if (v === '[configured]') continue; // masked placeholder — skip
updates[k] = v; // empty string = clear override
}
try {
saveSettings(updates);
cfg = loadConfig();
return json(res, 200, { ok: true });
} catch (err) {
return json(res, 500, { error: err.message });
}
}
res.writeHead(404); res.end('Not found');
});
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
loadJobLog();
loadPendingQueue();
processEncodeQueue();
server.listen(PORT, '0.0.0.0', () => {
console.log(`Discarr listening on http://0.0.0.0:${PORT}`);
console.log(`Config: ${CONFIG_PATH}`);
console.log(`Job log: ${LOG_PATH}`);
console.log(`Queue: ${QUEUE_PATH}`);
if (!fs.existsSync(CONFIG_PATH)) console.warn('WARNING: config not found');
});