Compare commits

..

5 commits
v0.1.2 ... main

Author SHA1 Message Date
7932a31cb8 docs: add development tooling disclosure 2026-05-27 14:05:14 -07:00
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
362a7499c2 fix: revert to Alpine base (Debian bookworm has 149 CVEs vs Alpine's ~36)
Debian bookworm is frozen at June 2023 package versions. Key problem:
  mbedtls 2.28.3-1 (bookworm) vs mbedtls 3.6.6-r0 (Alpine 3.23)

CVE-2026-34875 (9.8 critical) is fixed in mbedtls 3.6.6 — which Alpine
already ships. Debian bookworm won't get that update. Similarly for 5+
other critical/high mbedtls CVEs and gnutls28 CVEs. Total: 149 CVEs on
Debian bookworm vs ~36 on Alpine 3.23.

Alpine's rolling model ships much newer package versions, which actually
means fewer accumulated CVEs in key libraries like mbedtls, despite the
reputation of 'Debian stable = secure'.
2026-05-27 10:45:04 -07:00
93afa60b4f fix: switch to node:22-bookworm-slim (Debian) base for better CVE coverage
Alpine's community ffmpeg package had 4+ high CVEs open for 12+ months
(CVE-2023-51793/94/95/98) that Debian's security team backported patches
for in ffmpeg 5.1.9-0+deb12u1.

Changes:
- Dockerfile: node:22-bookworm-slim, apt-get ffmpeg (5.1.9 patched)
- Dockerfile.handbrake: same base, adds handbrake-cli
- CVE-2026-1837 (libjxl): not affected — bookworm ships libjxl 0.7.0
- CVE-2025-52194 (libsndfile): Debian marked not reproducible
- CVE-2026-3099x (ffmpeg AV1): postponed everywhere, no fix available

Tradeoff: image grows from ~300MB to ~677MB (Debian runtime overhead).
ffmpeg 5.1.9 has full feature coverage for disc scanning and HEVC encoding.
2026-05-27 10:36:38 -07:00
baf13ec14f docs: update Docker section for latest/handbrake tag split 2026-05-27 10:26:44 -07:00
6 changed files with 153 additions and 21 deletions

View file

@ -2,16 +2,20 @@
# ffmpeg/ffprobe: VIDEO_TS/BDMV metadata scanning and HEVC encode dispatch
# openssh-client: remote encode dispatch to SSH transcode workers
#
# HandBrake is NOT included in this image — ffmpeg handles encoding by default.
# If you need HandBrake (preset system, forced-subtitle burn-in), use the
# handbrake variant: pyr0ball/discarr:handbrake
# Or install HandBrake natively via: sudo bash install.sh
# Base: node:22-alpine (Alpine 3.23)
# Alpine's rolling package model ships significantly newer versions than
# Debian stable (bookworm, frozen at June 2023). Key examples:
# mbedtls: Alpine 3.6.6 (patched) vs Debian bookworm 2.28.3 (unpatched)
# ffmpeg: Alpine 8.0.1 vs Debian bookworm 5.1.x
#
# HandBrake is NOT included — ffmpeg handles encoding by default.
# For HandBrake presets or forced-subtitle burn-in:
# pyr0ball/discarr:handbrake (or build from Dockerfile.handbrake)
# Node 22 is the current LTS (Node 20 reached EOL 2026-04-30)
FROM node:22-alpine
# Upgrade all base packages first to pick up Alpine security patches,
# then add runtime dependencies in the same layer.
# Upgrade all packages to pick up any in-branch security patches,
# then add runtime deps in the same layer.
RUN apk upgrade --no-cache && \
apk add --no-cache \
ffmpeg \

View file

@ -91,21 +91,36 @@ sudo DISCARR_INSTALL_DIR=/opt/discarr DISCARR_PORT=8603 REGISTER_SERVICE=yes bas
### Docker
Pre-built image (includes ffmpeg, ffprobe, HandBrake, libdvd*, openssh-client):
Two pre-built variants are available:
| Tag | Includes | Use when |
|---|---|---|
| `latest` / `0.1.2` | ffmpeg, openssh-client | Default — ffmpeg handles all encoding |
| `handbrake` | + HandBrake | You need HandBrake presets or forced-subtitle burn-in |
```bash
# Default (recommended)
docker run -d \
-p 8603:8603 \
-v ~/.config/media-postprocessor:/root/.config/media-postprocessor:ro \
-v ~/.local/share/discarr:/root/.local/share/discarr \
-v /path/to/media:/media \
pyr0ball/discarr:latest
# HandBrake variant
docker run -d \
-p 8603:8603 \
-v ~/.config/media-postprocessor:/root/.config/media-postprocessor:ro \
-v ~/.local/share/discarr:/root/.local/share/discarr \
-v /path/to/media:/media \
pyr0ball/discarr:handbrake
```
Or build from source:
```bash
docker build -t discarr .
docker build -t discarr . # default
docker build -f Dockerfile.handbrake -t discarr:handbrake . # HandBrake variant
docker run -d \
-p 8603:8603 \
-v ~/.config/media-postprocessor:/root/.config/media-postprocessor:ro \
@ -173,6 +188,10 @@ All scripts respect the `DISCARR_URL` environment variable (default: `http://127
Issues and PRs welcome. Please open an issue before starting a large change.
## Development tooling
All code in this repo is reviewed, tested, and owned by human contributors. LLM (large language model) coding tools are part of our development workflow.
## License
GPL-3.0 — see [LICENSE](LICENSE).

View file

@ -27,3 +27,10 @@ ENCODE_SSH_HOST=user@your-encode-host
# FFMPEG_BIN=/usr/bin/ffmpeg
# FFPROBE_BIN=/usr/bin/ffprobe
# 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",
"version": "0.1.0",
"version": "0.2.0",
"description": "Disc scanning and HEVC encoding queue for Sonarr/Radarr",
"main": "server.js",
"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"
placeholder="/Library/Series" autocomplete="off" />
</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 class="settings-footer">
<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>
<select id="preset-select">
<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-qsv">HEVC QSV (Intel 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-nvenc">HandBrake NVENC HEVC</option>
</optgroup>
<optgroup label="User-defined" id="preset-user-optgroup" style="display:none"></optgroup>
<optgroup label="Custom">
<option value="custom">Custom script…</option>
</optgroup>
@ -457,6 +466,7 @@ const api = {
async function init() {
S.config = await api.get('/api/config').catch(()=>({}));
renderChips();
syncUserPresetDropdown();
const host = S.config.encodeHost;
if (host) document.getElementById('encode-host').value = host;
// Show/hide tabs based on what's configured
@ -1681,18 +1691,66 @@ document.getElementById('btn-close-settings').addEventListener('click', () => {
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() {
const settings = await api.get('/api/settings').catch(() => ({}));
for (const [key, elId] of Object.entries(settingsFieldMap)) {
const el = document.getElementById(elId);
if (!el) continue;
const val = settings[key] || '';
// For masked placeholders use the input placeholder text, not the value
el.value = val === '[configured]' ? '' : val;
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 () => {
const btn = document.getElementById('btn-save-settings');
const status = document.getElementById('settings-status');
@ -1706,12 +1764,27 @@ document.getElementById('btn-save-settings').addEventListener('click', async ()
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 {
await api.post('/api/settings', body);
// Re-probe capabilities and refresh chips
S.config = await api.get('/api/config').catch(() => S.config);
renderChips();
// Update encode-host field if changed
syncUserPresetDropdown();
if (body.ENCODE_SSH_HOST !== undefined) {
const encEl = document.getElementById('encode-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.className = 'save-status ok';
} catch(err) {
status.textContent = `Error: ${err.message}`;
status.textContent = 'Error: ' + err.message;
status.className = 'save-status err';
} finally {
btn.disabled = false;

View file

@ -40,6 +40,7 @@ const SETTINGS_KEYS = new Set([
'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)
@ -383,6 +384,7 @@ async function notifySonarr(outputPath) {
// 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'],
@ -390,6 +392,23 @@ const PRESETS = {
'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 = {
@ -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 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)));
// 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'] : [];
// 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];
@ -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 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)));
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,
...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath]
@ -941,7 +965,7 @@ const server = http.createServer(async (req, res) => {
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)],
presets: [...Object.keys(getEffectivePresets(cfg)), ...Object.keys(HANDBRAKE_PRESETS)],
});
}
@ -1128,6 +1152,10 @@ const server = http.createServer(async (req, res) => {
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'),
@ -1138,6 +1166,7 @@ const server = http.createServer(async (req, res) => {
FFMPEG_BIN: cfg.FFMPEG_BIN || '',
FFPROBE_BIN: cfg.FFPROBE_BIN || '',
OUTPUT_BASE: cfg.OUTPUT_BASE || '',
...presetEntries,
});
}
@ -1149,7 +1178,7 @@ const server = http.createServer(async (req, res) => {
const updates = {};
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
updates[k] = v; // empty string = clear override
}