Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7932a31cb8 | |||
| c9d6d97f80 | |||
| 362a7499c2 | |||
| 93afa60b4f | |||
| baf13ec14f | |||
| 9a1f0e0d39 |
7 changed files with 180 additions and 23 deletions
23
Dockerfile
23
Dockerfile
|
|
@ -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
24
Dockerfile.handbrake
Normal 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"]
|
||||
23
README.md
23
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
43
server.js
43
server.js
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue