Compare commits

..

6 commits
v0.1.1 ... 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
9a1f0e0d39 fix: drop HandBrake from default image, add :handbrake variant
Alpine's HandBrake package depends on both ffmpeg 8.x AND ffmpeg7 7.x,
doubling the ffmpeg CVE surface. HandBrake is optional (ffmpeg handles
encoding by default), so remove it from the default image.

- Dockerfile: ffmpeg + openssh-client only (removes ffmpeg7 family)
- Dockerfile.handbrake: new variant for users who need HandBrake presets
  or forced-subtitle burn-in; carries the known higher CVE count

Docker Hub tags:
  pyr0ball/discarr:latest / 0.1.2  — lean, ffmpeg only
  pyr0ball/discarr:handbrake        — includes HandBrake (more CVEs)
2026-05-27 10:26:25 -07:00
7 changed files with 180 additions and 23 deletions

View file

@ -1,22 +1,27 @@
# Discarr: disc scanning and encoding queue
# ffmpeg/ffprobe: VIDEO_TS/BDMV metadata scanning and local encode dispatch
# HandBrake: optional HEVC encoder (ffmpeg is the fallback)
# ffmpeg/ffprobe: VIDEO_TS/BDMV metadata scanning and HEVC encode dispatch
# openssh-client: remote encode dispatch to SSH transcode workers
#
# 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 to pick up security patches from Alpine before
# adding our own deps. Combining upgrade + add in one RUN avoids an extra
# layer and ensures the package index stays consistent.
# 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 \
handbrake \
openssh-client
# npm's bundled deps (tar, minimatch) carry their own CVE surface.
# Updating to latest npm gets the patched versions.
# Update npm to patch bundled tar/minimatch CVEs
RUN npm install -g npm@latest && npm cache clean --force
WORKDIR /app

24
Dockerfile.handbrake Normal file
View file

@ -0,0 +1,24 @@
# Discarr — HandBrake variant
# Includes HandBrake for preset-based encoding and forced-subtitle burn-in.
# NOTE: Alpine's HandBrake package depends on both ffmpeg 8.x AND ffmpeg 7.x,
# which increases the CVE surface area compared to the default image.
# Use this variant only if you specifically need HandBrake features.
#
# Build: docker build -f Dockerfile.handbrake -t pyr0ball/discarr:handbrake .
FROM node:22-alpine
RUN apk upgrade --no-cache && \
apk add --no-cache \
ffmpeg \
handbrake \
openssh-client
RUN npm install -g npm@latest && npm cache clean --force
WORKDIR /app
COPY server.js scanner.js ./
COPY public/ ./public/
EXPOSE 8603
CMD ["node", "server.js"]

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
}