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