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
This commit is contained in:
pyr0ball 2026-05-27 13:02:29 -07:00
parent 362a7499c2
commit c9d6d97f80
4 changed files with 121 additions and 12 deletions

View file

@ -27,3 +27,10 @@ ENCODE_SSH_HOST=user@your-encode-host
# FFMPEG_BIN=/usr/bin/ffmpeg # FFMPEG_BIN=/usr/bin/ffmpeg
# FFPROBE_BIN=/usr/bin/ffprobe # FFPROBE_BIN=/usr/bin/ffprobe
# OUTPUT_BASE=/path/to/output # OUTPUT_BASE=/path/to/output
# --- Custom encode presets (optional) ---
# PRESET_<name> adds a user-defined preset to the encode dropdown.
# Value is a space-separated list of ffmpeg codec arguments.
# Examples:
# PRESET_av1_svt=-c:v libsvtav1 -crf 30 -preset 6 -c:a copy
# PRESET_h264_hq=-c:v libx264 -crf 18 -preset slow -c:a copy

View file

@ -1,6 +1,6 @@
{ {
"name": "discarr", "name": "discarr",
"version": "0.1.0", "version": "0.2.0",
"description": "Disc scanning and HEVC encoding queue for Sonarr/Radarr", "description": "Disc scanning and HEVC encoding queue for Sonarr/Radarr",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View file

@ -266,6 +266,13 @@ input:focus-visible,select:focus-visible{outline:none}
<input type="text" id="cfg-output-base" name="OUTPUT_BASE" <input type="text" id="cfg-output-base" name="OUTPUT_BASE"
placeholder="/Library/Series" autocomplete="off" /> placeholder="/Library/Series" autocomplete="off" />
</div> </div>
<div class="settings-group-title" style="grid-column:1/-1">Custom Encode Presets</div>
<div id="cfg-presets-list" style="grid-column:1/-1;display:flex;flex-direction:column;gap:.4rem"></div>
<div style="grid-column:1/-1;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<button class="btn-ghost btn-sm" id="btn-add-preset" type="button">+ Add preset</button>
<span style="font-size:.7rem;color:var(--muted)">Name + ffmpeg codec args — e.g. <code style="background:var(--surface2);padding:.1rem .3rem;border-radius:3px">-c:v libsvtav1 -crf 30 -c:a copy</code></span>
</div>
</div> </div>
<div class="settings-footer"> <div class="settings-footer">
<button class="btn-primary btn-sm" id="btn-save-settings">Save Settings</button> <button class="btn-primary btn-sm" id="btn-save-settings">Save Settings</button>
@ -354,6 +361,7 @@ input:focus-visible,select:focus-visible{outline:none}
<label for="preset-select">Encode preset</label> <label for="preset-select">Encode preset</label>
<select id="preset-select"> <select id="preset-select">
<optgroup label="ffmpeg"> <optgroup label="ffmpeg">
<option value="remux">Remux (stream copy — no re-encode)</option>
<option value="hevc-nvenc">HEVC NVENC (NVIDIA GPU)</option> <option value="hevc-nvenc">HEVC NVENC (NVIDIA GPU)</option>
<option value="hevc-qsv">HEVC QSV (Intel GPU)</option> <option value="hevc-qsv">HEVC QSV (Intel GPU)</option>
<option value="hevc-vaapi">HEVC VAAPI (AMD GPU)</option> <option value="hevc-vaapi">HEVC VAAPI (AMD GPU)</option>
@ -365,6 +373,7 @@ input:focus-visible,select:focus-visible{outline:none}
<option value="handbrake-h264">HandBrake x264 H.264</option> <option value="handbrake-h264">HandBrake x264 H.264</option>
<option value="handbrake-nvenc">HandBrake NVENC HEVC</option> <option value="handbrake-nvenc">HandBrake NVENC HEVC</option>
</optgroup> </optgroup>
<optgroup label="User-defined" id="preset-user-optgroup" style="display:none"></optgroup>
<optgroup label="Custom"> <optgroup label="Custom">
<option value="custom">Custom script…</option> <option value="custom">Custom script…</option>
</optgroup> </optgroup>
@ -457,6 +466,7 @@ const api = {
async function init() { async function init() {
S.config = await api.get('/api/config').catch(()=>({})); S.config = await api.get('/api/config').catch(()=>({}));
renderChips(); renderChips();
syncUserPresetDropdown();
const host = S.config.encodeHost; const host = S.config.encodeHost;
if (host) document.getElementById('encode-host').value = host; if (host) document.getElementById('encode-host').value = host;
// Show/hide tabs based on what's configured // Show/hide tabs based on what's configured
@ -1681,17 +1691,65 @@ document.getElementById('btn-close-settings').addEventListener('click', () => {
document.getElementById('btn-settings').setAttribute('aria-expanded', 'false'); document.getElementById('btn-settings').setAttribute('aria-expanded', 'false');
}); });
let originalPresetKeys = new Set();
function makePresetRow(name, args) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:.4rem;align-items:center';
const nameEl = document.createElement('input');
nameEl.type = 'text'; nameEl.placeholder = 'preset-name';
nameEl.value = name;
nameEl.style.cssText = 'width:150px;flex-shrink:0;font-size:.82rem;padding:.3rem .5rem';
nameEl.dataset.role = 'preset-name';
const argsEl = document.createElement('input');
argsEl.type = 'text'; argsEl.placeholder = '-c:v libsvtav1 -crf 30 -c:a copy';
argsEl.value = args;
argsEl.style.cssText = 'flex:1;font-size:.82rem;padding:.3rem .5rem';
argsEl.dataset.role = 'preset-args';
const rmBtn = document.createElement('button');
rmBtn.type = 'button'; rmBtn.className = 'btn-ghost btn-xs';
rmBtn.textContent = '×'; rmBtn.title = 'Remove';
rmBtn.addEventListener('click', () => row.remove());
row.append(nameEl, argsEl, rmBtn);
return row;
}
function syncUserPresetDropdown() {
const og = document.getElementById('preset-user-optgroup');
og.replaceChildren();
const builtIn = new Set(['remux','hevc-nvenc','hevc-qsv','hevc-vaapi','x265-software','h264-nvenc',
'handbrake-hevc','handbrake-h264','handbrake-nvenc']);
const custom = (S.config.presets || []).filter(p => !builtIn.has(p));
custom.forEach(p => {
const opt = document.createElement('option');
opt.value = p; opt.textContent = p + ' (custom)';
og.appendChild(opt);
});
og.style.display = custom.length ? '' : 'none';
}
async function loadSettingsPanel() { async function loadSettingsPanel() {
const settings = await api.get('/api/settings').catch(() => ({})); const settings = await api.get('/api/settings').catch(() => ({}));
for (const [key, elId] of Object.entries(settingsFieldMap)) { for (const [key, elId] of Object.entries(settingsFieldMap)) {
const el = document.getElementById(elId); const el = document.getElementById(elId);
if (!el) continue; if (!el) continue;
const val = settings[key] || ''; const val = settings[key] || '';
// For masked placeholders use the input placeholder text, not the value
el.value = val === '[configured]' ? '' : val; el.value = val === '[configured]' ? '' : val;
if (val === '[configured]') el.placeholder = '[configured — leave blank to keep]'; if (val === '[configured]') el.placeholder = '[configured — leave blank to keep]';
} }
const list = document.getElementById('cfg-presets-list');
list.replaceChildren();
originalPresetKeys = new Set();
for (const [k, v] of Object.entries(settings)) {
if (!k.startsWith('PRESET_')) continue;
originalPresetKeys.add(k);
list.appendChild(makePresetRow(k.slice(7).toLowerCase().replace(/_/g, '-'), v));
} }
}
document.getElementById('btn-add-preset').addEventListener('click', () => {
document.getElementById('cfg-presets-list').appendChild(makePresetRow('', ''));
});
document.getElementById('btn-save-settings').addEventListener('click', async () => { document.getElementById('btn-save-settings').addEventListener('click', async () => {
const btn = document.getElementById('btn-save-settings'); const btn = document.getElementById('btn-save-settings');
@ -1706,12 +1764,27 @@ document.getElementById('btn-save-settings').addEventListener('click', async ()
if (el) body[key] = el.value.trim(); if (el) body[key] = el.value.trim();
} }
// Collect custom preset rows
const currentPresetKeys = new Set();
for (const row of document.getElementById('cfg-presets-list').children) {
const nameRaw = row.querySelector('[data-role="preset-name"]')?.value.trim() || '';
const args = row.querySelector('[data-role="preset-args"]')?.value.trim() || '';
if (!nameRaw) continue;
const key = 'PRESET_' + nameRaw.toUpperCase().replace(/-/g, '_').replace(/[^A-Z0-9_]/g, '');
if (!key.slice(7)) continue;
body[key] = args;
currentPresetKeys.add(key);
}
// Clear any preset keys the user removed
for (const k of originalPresetKeys) {
if (!currentPresetKeys.has(k)) body[k] = '';
}
try { try {
await api.post('/api/settings', body); await api.post('/api/settings', body);
// Re-probe capabilities and refresh chips
S.config = await api.get('/api/config').catch(() => S.config); S.config = await api.get('/api/config').catch(() => S.config);
renderChips(); renderChips();
// Update encode-host field if changed syncUserPresetDropdown();
if (body.ENCODE_SSH_HOST !== undefined) { if (body.ENCODE_SSH_HOST !== undefined) {
const encEl = document.getElementById('encode-host'); const encEl = document.getElementById('encode-host');
if (encEl && body.ENCODE_SSH_HOST) encEl.value = body.ENCODE_SSH_HOST; if (encEl && body.ENCODE_SSH_HOST) encEl.value = body.ENCODE_SSH_HOST;
@ -1719,7 +1792,7 @@ document.getElementById('btn-save-settings').addEventListener('click', async ()
status.textContent = '✓ Saved'; status.textContent = '✓ Saved';
status.className = 'save-status ok'; status.className = 'save-status ok';
} catch(err) { } catch(err) {
status.textContent = `Error: ${err.message}`; status.textContent = 'Error: ' + err.message;
status.className = 'save-status err'; status.className = 'save-status err';
} finally { } finally {
btn.disabled = false; btn.disabled = false;

View file

@ -40,6 +40,7 @@ const SETTINGS_KEYS = new Set([
'TDARR_URL','TDARR_LIBRARY_ID','ENCODE_SSH_HOST', 'TDARR_URL','TDARR_LIBRARY_ID','ENCODE_SSH_HOST',
'FFMPEG_BIN','FFPROBE_BIN','OUTPUT_BASE', 'FFMPEG_BIN','FFPROBE_BIN','OUTPUT_BASE',
]); ]);
const PRESET_KEY_RE = /^PRESET_[A-Z0-9_]+$/;
function loadConfig() { function loadConfig() {
// Load primary config (may be read-only in Docker) // Load primary config (may be read-only in Docker)
@ -383,6 +384,7 @@ async function notifySonarr(outputPath) {
// Encode presets // Encode presets
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const PRESETS = { const PRESETS = {
'remux': ['-c', 'copy'],
'hevc-nvenc': ['-c:v','hevc_nvenc','-rc','constqp','-qp','22','-preset','p4','-c:a','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-qsv': ['-c:v','hevc_qsv','-global_quality','22','-c:a','copy'],
'hevc-vaapi': ['-c:v','hevc_vaapi','-qp','22','-c:a','copy'], 'hevc-vaapi': ['-c:v','hevc_vaapi','-qp','22','-c:a','copy'],
@ -390,6 +392,23 @@ const PRESETS = {
'h264-nvenc': ['-c:v','h264_nvenc','-rc','constqp','-qp','22','-preset','p4','-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() // HandBrakeCLI preset templates — substitution handled in buildHandbrakeCmd()
// These use {dvd_path}, {title_set}, {input}, {output} tokens (not ffmpeg args) // These use {dvd_path}, {title_set}, {input}, {output} tokens (not ffmpeg args)
const HANDBRAKE_PRESETS = { const HANDBRAKE_PRESETS = {
@ -487,7 +506,7 @@ async function runLocalEncode(job, mapping) {
}); });
} }
const presetArgs = PRESETS[preset] || PRESETS['x265-software']; const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg'; const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const inputArgs = diskType === 'dvd' const inputArgs = diskType === 'dvd'
@ -518,8 +537,11 @@ async function runLocalEncode(job, mapping) {
} }
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0))); 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. // pcm_dvd audio cannot be stream-copied into MKV. For remux use lossless FLAC;
const audioOverride = diskType === 'dvd' ? ['-c:a', 'aac', '-b:a', '192k'] : []; // 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, const args = ['-y', ...preInputArgs, ...inputArgs, ...trackArgs,
...postInputArgs, ...presetArgs, ...audioOverride, outputPath]; ...postInputArgs, ...presetArgs, ...audioOverride, outputPath];
@ -592,7 +614,7 @@ async function runSshEncode(job, mapping, sshHost) {
}); });
} }
const presetArgs = PRESETS[preset] || PRESETS['x265-software']; const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg'; const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const concatInput = diskType === 'dvd' const concatInput = diskType === 'dvd'
@ -616,7 +638,9 @@ async function runSshEncode(job, mapping, sshHost) {
} }
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0))); if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0)));
const audioOverride = diskType === 'dvd' ? ['-c:a', 'aac', '-b:a', '192k'] : []; const audioOverride = diskType === 'dvd'
? (preset === 'remux' ? ['-c:a', 'flac'] : ['-c:a', 'aac', '-b:a', '192k'])
: [];
const ffmpegArgs = [...preInputArgs, '-y', '-i', concatInput, const ffmpegArgs = [...preInputArgs, '-y', '-i', concatInput,
...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath] ...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath]
@ -941,7 +965,7 @@ const server = http.createServer(async (req, res) => {
tdarr: !!(cfg.TDARR_URL && cfg.TDARR_LIBRARY_ID), tdarr: !!(cfg.TDARR_URL && cfg.TDARR_LIBRARY_ID),
encodeHost: cfg.ENCODE_SSH_HOST || null, encodeHost: cfg.ENCODE_SSH_HOST || null,
outputBase: cfg.OUTPUT_BASE || null, outputBase: cfg.OUTPUT_BASE || null,
presets: [...Object.keys(PRESETS), ...Object.keys(HANDBRAKE_PRESETS)], presets: [...Object.keys(getEffectivePresets(cfg)), ...Object.keys(HANDBRAKE_PRESETS)],
}); });
} }
@ -1128,6 +1152,10 @@ const server = http.createServer(async (req, res) => {
cfg = loadConfig(); cfg = loadConfig();
// Return URL/non-secret values as-is; mask API keys so they're not sent to browser // Return URL/non-secret values as-is; mask API keys so they're not sent to browser
const masked = k => cfg[k] ? '[configured]' : ''; 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, { return json(res, 200, {
SONARR_URL: cfg.SONARR_URL || '', SONARR_URL: cfg.SONARR_URL || '',
SONARR_API_KEY: masked('SONARR_API_KEY'), SONARR_API_KEY: masked('SONARR_API_KEY'),
@ -1138,6 +1166,7 @@ const server = http.createServer(async (req, res) => {
FFMPEG_BIN: cfg.FFMPEG_BIN || '', FFMPEG_BIN: cfg.FFMPEG_BIN || '',
FFPROBE_BIN: cfg.FFPROBE_BIN || '', FFPROBE_BIN: cfg.FFPROBE_BIN || '',
OUTPUT_BASE: cfg.OUTPUT_BASE || '', OUTPUT_BASE: cfg.OUTPUT_BASE || '',
...presetEntries,
}); });
} }
@ -1149,7 +1178,7 @@ const server = http.createServer(async (req, res) => {
const updates = {}; const updates = {};
for (const [k, v] of Object.entries(body)) { for (const [k, v] of Object.entries(body)) {
if (!SETTINGS_KEYS.has(k) || typeof v !== 'string') continue; if ((!SETTINGS_KEYS.has(k) && !PRESET_KEY_RE.test(k)) || typeof v !== 'string') continue;
if (v === '[configured]') continue; // masked placeholder — skip if (v === '[configured]') continue; // masked placeholder — skip
updates[k] = v; // empty string = clear override updates[k] = v; // empty string = clear override
} }