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:
parent
362a7499c2
commit
c9d6d97f80
4 changed files with 121 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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,18 +1691,66 @@ 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');
|
||||||
const status = document.getElementById('settings-status');
|
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();
|
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;
|
||||||
|
|
|
||||||
43
server.js
43
server.js
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue