Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

7 changed files with 23 additions and 180 deletions

View file

@ -1,27 +1,22 @@
# Discarr: disc scanning and encoding queue
# ffmpeg/ffprobe: VIDEO_TS/BDMV metadata scanning and HEVC encode dispatch
# ffmpeg/ffprobe: VIDEO_TS/BDMV metadata scanning and local encode dispatch
# HandBrake: optional HEVC encoder (ffmpeg is the fallback)
# 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 packages to pick up any in-branch security patches,
# then add runtime deps in the same layer.
# 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.
RUN apk upgrade --no-cache && \
apk add --no-cache \
ffmpeg \
handbrake \
openssh-client
# Update npm to patch bundled tar/minimatch CVEs
# npm's bundled deps (tar, minimatch) carry their own CVE surface.
# Updating to latest npm gets the patched versions.
RUN npm install -g npm@latest && npm cache clean --force
WORKDIR /app

View file

@ -1,24 +0,0 @@
# 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,36 +91,21 @@ sudo DISCARR_INSTALL_DIR=/opt/discarr DISCARR_PORT=8603 REGISTER_SERVICE=yes bas
### Docker
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 |
Pre-built image (includes ffmpeg, ffprobe, HandBrake, libdvd*, openssh-client):
```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 . # default
docker build -f Dockerfile.handbrake -t discarr:handbrake . # HandBrake variant
docker build -t discarr .
docker run -d \
-p 8603:8603 \
-v ~/.config/media-postprocessor:/root/.config/media-postprocessor:ro \
@ -188,10 +173,6 @@ 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,10 +27,3 @@ 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.2.0",
"version": "0.1.0",
"description": "Disc scanning and HEVC encoding queue for Sonarr/Radarr",
"main": "server.js",
"scripts": {

View file

@ -266,13 +266,6 @@ 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>
@ -361,7 +354,6 @@ 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>
@ -373,7 +365,6 @@ 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>
@ -466,7 +457,6 @@ 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
@ -1691,66 +1681,18 @@ 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');
@ -1764,27 +1706,12 @@ 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();
syncUserPresetDropdown();
// Update encode-host field if changed
if (body.ENCODE_SSH_HOST !== undefined) {
const encEl = document.getElementById('encode-host');
if (encEl && body.ENCODE_SSH_HOST) encEl.value = body.ENCODE_SSH_HOST;
@ -1792,7 +1719,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,7 +40,6 @@ 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)
@ -384,7 +383,6 @@ 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'],
@ -392,23 +390,6 @@ 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 = {
@ -506,7 +487,7 @@ async function runLocalEncode(job, mapping) {
});
}
const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const presetArgs = PRESETS[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const inputArgs = diskType === 'dvd'
@ -537,11 +518,8 @@ async function runLocalEncode(job, mapping) {
}
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0)));
// pcm_dvd audio cannot be stream-copied into MKV. For remux use lossless FLAC;
// for encode presets transcode to AAC. BDMV sources need no override.
const audioOverride = diskType === 'dvd'
? (preset === 'remux' ? ['-c:a', 'flac'] : ['-c:a', 'aac', '-b:a', '192k'])
: [];
// 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];
@ -614,7 +592,7 @@ async function runSshEncode(job, mapping, sshHost) {
});
}
const presetArgs = getEffectivePresets(cfg)[preset] || PRESETS['x265-software'];
const presetArgs = PRESETS[preset] || PRESETS['x265-software'];
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const concatInput = diskType === 'dvd'
@ -638,9 +616,7 @@ async function runSshEncode(job, mapping, sshHost) {
}
if (mapping.endSec > 0) postInputArgs.push('-t', String(mapping.endSec - (mapping.startSec || 0)));
const audioOverride = diskType === 'dvd'
? (preset === 'remux' ? ['-c:a', 'flac'] : ['-c:a', 'aac', '-b:a', '192k'])
: [];
const audioOverride = diskType === 'dvd' ? ['-c:a', 'aac', '-b:a', '192k'] : [];
const ffmpegArgs = [...preInputArgs, '-y', '-i', concatInput,
...trackArgs, ...postInputArgs, ...presetArgs, ...audioOverride, outputPath]
@ -965,7 +941,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(getEffectivePresets(cfg)), ...Object.keys(HANDBRAKE_PRESETS)],
presets: [...Object.keys(PRESETS), ...Object.keys(HANDBRAKE_PRESETS)],
});
}
@ -1152,10 +1128,6 @@ 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'),
@ -1166,7 +1138,6 @@ const server = http.createServer(async (req, res) => {
FFMPEG_BIN: cfg.FFMPEG_BIN || '',
FFPROBE_BIN: cfg.FFPROBE_BIN || '',
OUTPUT_BASE: cfg.OUTPUT_BASE || '',
...presetEntries,
});
}
@ -1178,7 +1149,7 @@ const server = http.createServer(async (req, res) => {
const updates = {};
for (const [k, v] of Object.entries(body)) {
if ((!SETTINGS_KEYS.has(k) && !PRESET_KEY_RE.test(k)) || typeof v !== 'string') continue;
if (!SETTINGS_KEYS.has(k) || typeof v !== 'string') continue;
if (v === '[configured]') continue; // masked placeholder — skip
updates[k] = v; // empty string = clear override
}