video quality profile UI: rich-curated ladder + cutoff + rejects + soft prefs

Rebuilds the Video Quality Profile card to match the new Radarr-class model:

  - Quality ladder: one ranked, toggleable source×resolution list (Remux·4K …
    SDTV) reusing the .hybrid-source-item styling (parity with Download Source).
    The cutoff tier shows an accent rail + 'cutoff' tag.
  - Upgrade until: a styled dropdown of the enabled tiers (the cutoff).
  - Never grab: red toggle chips for the hard rejects (cam/screener/workprint/3d/x264).
  - Preferences: segmented controls for codec (Any/HEVC/AV1), HDR (Don't care/
    Prefer/Require), audio (Any/Surround/Lossless/Atmos) + a Prefer REPACK check —
    all soft tie-breakers.
  - Size guard: min + max GB sliders (0 = no limit).

Dropped the old resolutions/source_priority/single-codec/fallback widgets. JS
delegation rewritten for the new data-vq-* hooks; same-verdict-as-HEAD balance,
div balance clean. Pure UI on the isolated video Downloads tab.
This commit is contained in:
BoulderBadgeDad 2026-06-19 08:43:47 -07:00
parent 23cefd3c15
commit cd13b1185e
3 changed files with 178 additions and 91 deletions

View file

@ -6110,40 +6110,65 @@
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>Video Quality Profile</h3>
<div class="setting-help-text">
One profile for every video source. Quality is read from the release name / filename — choose what you'll accept, then how to break ties.
One profile for every video source (slskd · torrent · usenet). Quality is read from the release name — pick the ladder you'll accept, where to stop upgrading, what to reject outright, then the soft tie-breakers.
</div>
<div class="vq-block">
<div class="vq-label">Resolutions to accept <span class="vq-hint">toggle on what you want · order = preference (best first)</span></div>
<div class="hybrid-source-list" id="vq-resolution-rows"></div>
<div class="vq-label">Quality ladder <span class="vq-hint">toggle what you'll accept · rank best&rarr;worst with the arrows</span></div>
<div class="hybrid-source-list" id="vq-tier-rows"></div>
</div>
<div class="vq-block">
<div class="vq-label">Preferred source <span class="vq-hint">tie-breaker when two releases match the same resolution</span></div>
<div class="hybrid-source-list" id="vq-source-rows"></div>
<div class="vq-label">Upgrade until <span class="vq-hint">once the library holds this tier (or better) stop grabbing upgrades</span></div>
<select id="vq-cutoff" class="vq-select"></select>
</div>
<div class="vq-block">
<div class="vq-label">Never grab <span class="vq-hint">hard rejects &mdash; skipped no matter what else matches</span></div>
<div class="vq-chips" id="vq-rejects"></div>
</div>
<div class="vq-block">
<div class="vq-label">Preferences <span class="vq-hint">soft tie-breakers &mdash; they rank candidates, they never reject on their own</span></div>
<div class="vq-prefs">
<div class="vq-pref-row">
<span class="vq-pref-name">Codec</span>
<div class="vq-seg" id="vq-codec">
<button type="button" data-vq-codec="any">Any</button>
<button type="button" data-vq-codec="hevc">HEVC / x265</button>
<button type="button" data-vq-codec="av1">AV1</button>
</div>
</div>
<div class="vq-pref-row">
<span class="vq-pref-name">HDR</span>
<div class="vq-seg" id="vq-hdr">
<button type="button" data-vq-hdr="off">Don't care</button>
<button type="button" data-vq-hdr="prefer">Prefer</button>
<button type="button" data-vq-hdr="require">Require</button>
</div>
</div>
<div class="vq-pref-row">
<span class="vq-pref-name">Audio</span>
<div class="vq-seg" id="vq-audio">
<button type="button" data-vq-audio="any">Any</button>
<button type="button" data-vq-audio="surround">Surround</button>
<button type="button" data-vq-audio="lossless">Lossless</button>
<button type="button" data-vq-audio="atmos">Atmos</button>
</div>
</div>
</div>
<label class="vq-check" style="margin-top:12px;">
<input type="checkbox" id="vq-prefer-repack">
<span class="vq-check-box"></span>
<span>Prefer REPACK / PROPER re-releases</span>
</label>
</div>
<div class="vq-grid">
<div class="vq-block" style="margin:0;">
<div class="vq-label">Codec</div>
<div class="vq-seg" id="vq-codec">
<button type="button" data-vq-codec="any">Any</button>
<button type="button" data-vq-codec="x265">x265 / HEVC</button>
<button type="button" data-vq-codec="x264">x264</button>
</div>
<div class="vq-label">Min size per item <span class="vq-hint" id="vq-min-label">No limit</span></div>
<input type="range" id="vq-min-size" class="vq-slider" min="0" max="200" step="1" value="0">
</div>
<div class="vq-block" style="margin:0;">
<div class="vq-label">Max size per item <span class="vq-hint" id="vq-max-label">No limit</span></div>
<input type="range" id="vq-max-size" class="vq-slider" min="0" max="200" step="1" value="0">
</div>
<label class="vq-check">
<input type="checkbox" id="vq-prefer-hdr">
<span class="vq-check-box"></span>
<span>Prefer HDR / Dolby Vision</span>
</label>
</div>
<div class="vq-block">
<div class="vq-label">Max size per item <span class="vq-hint" id="vq-size-label">No limit</span></div>
<input type="range" id="vq-max-size" class="vq-slider" min="0" max="200" step="1" value="0">
</div>
<label class="vq-check">
<input type="checkbox" id="vq-fallback">
<span class="vq-check-box"></span>
<span>Allow lower-quality fallback when nothing in your enabled tiers is found</span>
</label>
</div>
<!-- Download Settings -->
<div class="settings-group" data-stg="downloads">

View file

@ -19,8 +19,18 @@
var QUALITY_URL = '/api/video/downloads/quality';
var SLSKD_URL = '/api/video/downloads/slskd';
var _videoQuality = null;
var RES_LABEL = { '2160p': '4K (2160p)', '1080p': '1080p', '720p': '720p', '480p': '480p (SD)' };
var SRC_LABEL = { 'bluray': 'BluRay', 'web-dl': 'WEB-DL', 'webrip': 'WEBRip', 'hdtv': 'HDTV' };
// Pretty labels for the source×resolution quality ladder (keys come from the backend).
var TIER_LABEL = {
'remux-2160p': 'Remux · 4K', 'bluray-2160p': 'BluRay · 4K', 'web-2160p': 'WEB · 4K',
'remux-1080p': 'Remux · 1080p', 'bluray-1080p': 'BluRay · 1080p', 'web-1080p': 'WEB-DL · 1080p',
'webrip-1080p': 'WEBRip · 1080p', 'hdtv-1080p': 'HDTV · 1080p',
'bluray-720p': 'BluRay · 720p', 'web-720p': 'WEB-DL · 720p', 'hdtv-720p': 'HDTV · 720p',
'dvd': 'DVD', 'sdtv': 'SDTV'
};
var REJECT_LABEL = {
'cam': 'CAM / TS', 'screener': 'Screener', 'workprint': 'Workprint', '3d': '3D', 'x264': 'x264 / AVC'
};
var REJECT_ORDER = ['cam', 'screener', 'workprint', '3d', 'x264'];
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@ -442,70 +452,89 @@
.catch(function () { /* ignore */ });
}
function _vqSizeLabel(id, v) {
var lab = document.getElementById(id);
if (lab) lab.textContent = v ? (v + ' GB') : 'No limit';
}
function _vqSeg(id, attr, value) {
var seg = document.getElementById(id);
if (!seg) return;
Array.prototype.forEach.call(seg.querySelectorAll('[' + attr + ']'), function (b) {
b.classList.toggle('active', b.getAttribute(attr) === value);
});
}
function renderQuality() {
var p = _videoQuality;
if (!p) return;
var resHost = document.getElementById('vq-resolution-rows');
if (resHost) {
var keys = Object.keys(p.resolutions).sort(function (a, b) {
return p.resolutions[a].priority - p.resolutions[b].priority;
});
resHost.innerHTML = keys.map(function (k, i) {
var r = p.resolutions[k];
return '<div class="hybrid-source-item' + (r.enabled ? '' : ' disabled') + '">' +
var tiers = Array.isArray(p.tiers) ? p.tiers : [];
// Quality ladder — ranked, toggleable (same .hybrid-source-item styling as Download Source).
var host = document.getElementById('vq-tier-rows');
if (host) {
host.innerHTML = tiers.map(function (t, i) {
var cut = (p.cutoff === t.key);
return '<div class="hybrid-source-item' + (t.enabled ? '' : ' disabled') + (cut ? ' vq-cut' : '') + '">' +
'<span class="hybrid-source-arrows">' +
'<button type="button" class="hybrid-arrow-btn" data-vq-res-move="' + k + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + ' title="Move up">▲</button>' +
'<button type="button" class="hybrid-arrow-btn" data-vq-res-move="' + k + '" data-dir="1"' + (i === keys.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' +
'<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + ' title="Move up">▲</button>' +
'<button type="button" class="hybrid-arrow-btn" data-vq-tier-move="' + t.key + '" data-dir="1"' + (i === tiers.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' +
'</span>' +
'<span class="hybrid-source-name">' + (RES_LABEL[k] || k) + '</span>' +
'<span class="hybrid-source-name">' + (TIER_LABEL[t.key] || t.key) + (cut ? ' <span class="vq-cut-tag">cutoff</span>' : '') + '</span>' +
'<span class="hybrid-source-priority">' + (i + 1) + '</span>' +
'<label class="hybrid-source-toggle"><input type="checkbox" data-vq-res-toggle="' + k + '"' + (r.enabled ? ' checked' : '') + '><span class="toggle-track"></span></label>' +
'<label class="hybrid-source-toggle"><input type="checkbox" data-vq-tier-toggle="' + t.key + '"' + (t.enabled ? ' checked' : '') + '><span class="toggle-track"></span></label>' +
'</div>';
}).join('');
}
var srcHost = document.getElementById('vq-source-rows');
if (srcHost) {
srcHost.innerHTML = p.source_priority.map(function (s, i) {
return '<div class="hybrid-source-item">' +
'<span class="hybrid-source-arrows">' +
'<button type="button" class="hybrid-arrow-btn" data-vq-src-move="' + s + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + ' title="Move up">▲</button>' +
'<button type="button" class="hybrid-arrow-btn" data-vq-src-move="' + s + '" data-dir="1"' + (i === p.source_priority.length - 1 ? ' disabled' : '') + ' title="Move down">▼</button>' +
'</span>' +
'<span class="hybrid-source-name">' + (SRC_LABEL[s] || s) + '</span>' +
'<span class="hybrid-source-priority">' + (i + 1) + '</span>' +
'</div>';
// Cutoff dropdown — only the enabled tiers are sensible upgrade targets.
var cut = document.getElementById('vq-cutoff');
if (cut) {
var enabled = tiers.filter(function (t) { return t.enabled; });
cut.innerHTML = enabled.map(function (t) {
return '<option value="' + t.key + '"' + (p.cutoff === t.key ? ' selected' : '') + '>' + (TIER_LABEL[t.key] || t.key) + '</option>';
}).join('');
if (!enabled.length) cut.innerHTML = '<option value="">— enable a tier first —</option>';
}
// Hard rejects — toggle chips (on = blocked).
var rj = document.getElementById('vq-rejects');
if (rj) {
var set = Array.isArray(p.rejects) ? p.rejects : [];
rj.innerHTML = REJECT_ORDER.map(function (k) {
var on = set.indexOf(k) !== -1;
return '<button type="button" class="vq-chip' + (on ? ' on' : '') + '" data-vq-reject="' + k + '">' + (REJECT_LABEL[k] || k) + '</button>';
}).join('');
}
var seg = document.getElementById('vq-codec');
if (seg) {
Array.prototype.forEach.call(seg.querySelectorAll('[data-vq-codec]'), function (b) {
b.classList.toggle('active', b.getAttribute('data-vq-codec') === p.codec);
});
}
var hdr = document.getElementById('vq-prefer-hdr'); if (hdr) hdr.checked = !!p.prefer_hdr;
var fb = document.getElementById('vq-fallback'); if (fb) fb.checked = p.fallback_enabled !== false;
var sl = document.getElementById('vq-max-size'); if (sl) sl.value = p.max_size_gb || 0;
var lab = document.getElementById('vq-size-label');
if (lab) lab.textContent = p.max_size_gb ? (p.max_size_gb + ' GB') : 'No limit';
// Soft preferences.
_vqSeg('vq-codec', 'data-vq-codec', p.prefer_codec);
_vqSeg('vq-hdr', 'data-vq-hdr', p.prefer_hdr);
_vqSeg('vq-audio', 'data-vq-audio', p.prefer_audio);
var rep = document.getElementById('vq-prefer-repack'); if (rep) rep.checked = !!p.prefer_repack;
// Size guard.
var mn = document.getElementById('vq-min-size'); if (mn) mn.value = p.min_size_gb || 0;
var mx = document.getElementById('vq-max-size'); if (mx) mx.value = p.max_size_gb || 0;
_vqSizeLabel('vq-min-label', p.min_size_gb || 0);
_vqSizeLabel('vq-max-label', p.max_size_gb || 0);
}
function moveRes(k, dir) {
var p = _videoQuality; if (!p) return;
var keys = Object.keys(p.resolutions).sort(function (a, b) {
return p.resolutions[a].priority - p.resolutions[b].priority;
});
var i = keys.indexOf(k), j = i + dir;
if (j < 0 || j >= keys.length) return;
keys[i] = keys[j]; keys[j] = k; // swap
keys.forEach(function (key, idx) { p.resolutions[key].priority = idx + 1; });
function moveTier(k, dir) {
var p = _videoQuality; if (!p || !Array.isArray(p.tiers)) return;
var arr = p.tiers, i = -1;
for (var n = 0; n < arr.length; n++) { if (arr[n].key === k) { i = n; break; } }
var j = i + dir;
if (i < 0 || j < 0 || j >= arr.length) return;
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; // swap
renderQuality(); saveQuality(true);
}
function moveSrc(s, dir) {
function toggleReject(k) {
var p = _videoQuality; if (!p) return;
var arr = p.source_priority, i = arr.indexOf(s), j = i + dir;
if (j < 0 || j >= arr.length) return;
arr[i] = arr[j]; arr[j] = s;
if (!Array.isArray(p.rejects)) p.rejects = [];
var i = p.rejects.indexOf(k);
if (i === -1) p.rejects.push(k); else p.rejects.splice(i, 1);
renderQuality(); saveQuality(true);
}
@ -521,33 +550,41 @@
// Delegated handlers for the quality profile (rows re-render, so delegate).
function wireQuality() {
var sec = document.getElementById('vq-resolution-rows');
var sec = document.getElementById('vq-tier-rows');
if (!sec) return;
var card = sec.closest('.settings-group');
if (!card || card._vqWired) return;
card._vqWired = true;
card.addEventListener('click', function (e) {
var rm = e.target.closest('[data-vq-res-move]');
if (rm) { moveRes(rm.getAttribute('data-vq-res-move'), parseInt(rm.getAttribute('data-dir'), 10)); return; }
var sm = e.target.closest('[data-vq-src-move]');
if (sm) { moveSrc(sm.getAttribute('data-vq-src-move'), parseInt(sm.getAttribute('data-dir'), 10)); return; }
var tm = e.target.closest('[data-vq-tier-move]');
if (tm) { moveTier(tm.getAttribute('data-vq-tier-move'), parseInt(tm.getAttribute('data-dir'), 10)); return; }
var rj = e.target.closest('[data-vq-reject]');
if (rj) { toggleReject(rj.getAttribute('data-vq-reject')); return; }
if (!_videoQuality) return;
var cd = e.target.closest('[data-vq-codec]');
if (cd && _videoQuality) { _videoQuality.codec = cd.getAttribute('data-vq-codec'); renderQuality(); saveQuality(true); }
if (cd) { _videoQuality.prefer_codec = cd.getAttribute('data-vq-codec'); renderQuality(); saveQuality(true); return; }
var hd = e.target.closest('[data-vq-hdr]');
if (hd) { _videoQuality.prefer_hdr = hd.getAttribute('data-vq-hdr'); renderQuality(); saveQuality(true); return; }
var au = e.target.closest('[data-vq-audio]');
if (au) { _videoQuality.prefer_audio = au.getAttribute('data-vq-audio'); renderQuality(); saveQuality(true); return; }
});
card.addEventListener('change', function (e) {
if (!_videoQuality) return;
var rt = e.target.closest('[data-vq-res-toggle]');
if (rt) { _videoQuality.resolutions[rt.getAttribute('data-vq-res-toggle')].enabled = rt.checked; renderQuality(); saveQuality(true); return; }
if (e.target.id === 'vq-prefer-hdr') { _videoQuality.prefer_hdr = e.target.checked; saveQuality(true); return; }
if (e.target.id === 'vq-fallback') { _videoQuality.fallback_enabled = e.target.checked; saveQuality(true); return; }
var tt = e.target.closest('[data-vq-tier-toggle]');
if (tt) {
var key = tt.getAttribute('data-vq-tier-toggle');
var arr = _videoQuality.tiers || [];
for (var n = 0; n < arr.length; n++) { if (arr[n].key === key) { arr[n].enabled = tt.checked; break; } }
renderQuality(); saveQuality(true); return;
}
if (e.target.id === 'vq-cutoff') { _videoQuality.cutoff = e.target.value; renderQuality(); saveQuality(true); return; }
if (e.target.id === 'vq-prefer-repack') { _videoQuality.prefer_repack = e.target.checked; saveQuality(true); return; }
if (e.target.id === 'vq-min-size') { _videoQuality.min_size_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; }
if (e.target.id === 'vq-max-size') { _videoQuality.max_size_gb = parseInt(e.target.value, 10) || 0; saveQuality(true); return; }
});
card.addEventListener('input', function (e) {
if (e.target.id === 'vq-max-size' && _videoQuality) {
var v = parseInt(e.target.value, 10) || 0;
var lab = document.getElementById('vq-size-label');
if (lab) lab.textContent = v ? (v + ' GB') : 'No limit';
}
if (e.target.id === 'vq-min-size') { _vqSizeLabel('vq-min-label', parseInt(e.target.value, 10) || 0); return; }
if (e.target.id === 'vq-max-size') { _vqSizeLabel('vq-max-label', parseInt(e.target.value, 10) || 0); return; }
});
}

View file

@ -2969,8 +2969,33 @@ body[data-side="video"] #soulsync-toggle { display: none; }
color: rgba(255,255,255,0.65); margin-bottom: 9px; display: flex; align-items: center; gap: 10px; }
.vq-hint { font-size: 11px; font-weight: 500; text-transform: none; letter-spacing: 0;
color: rgba(255,255,255,0.4); }
/* Resolution + source rows reuse music's .hybrid-source-item styling (set in the
shared style.css) for parity with the Download Source list no custom row CSS. */
/* Quality ladder rows reuse music's .hybrid-source-item styling (set in the shared
style.css) for parity with the Download Source list no custom row CSS. The
cutoff tier gets a subtle accent rail + tag. */
.hybrid-source-item.vq-cut { border-left: 3px solid rgb(var(--accent-rgb));
background: rgba(var(--accent-rgb),0.07); }
.vq-cut-tag { display: inline-block; margin-left: 9px; padding: 2px 8px; border-radius: 999px;
font-size: 10px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase;
background: rgb(var(--accent-rgb)); color: #06210f; vertical-align: middle; }
/* cutoff dropdown */
.vq-select { width: 100%; max-width: 360px; padding: 10px 13px; border-radius: 10px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff;
font-size: 13.5px; font-weight: 600; cursor: pointer; outline: none; transition: border-color 0.15s; }
.vq-select:hover, .vq-select:focus { border-color: rgba(var(--accent-rgb),0.6); }
.vq-select option { background: #14181f; color: #fff; }
/* hard-reject chips (on = blocked) */
.vq-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.vq-chip { padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 12.5px; font-weight: 600;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12);
color: rgba(255,255,255,0.6); transition: all 0.15s; }
.vq-chip:hover { color: #fff; border-color: rgba(255,255,255,0.25); }
.vq-chip.on { background: rgba(239,68,68,0.18); border-color: rgba(239,68,68,0.55); color: #fecaca; }
.vq-chip.on::before { content: '⊘ '; font-weight: 800; }
/* preference rows (label + segmented control) */
.vq-prefs { display: flex; flex-direction: column; gap: 11px; }
.vq-pref-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.vq-pref-name { font-size: 13px; font-weight: 700; color: rgba(255,255,255,0.75);
min-width: 56px; }
/* codec + hdr row */
.vq-grid { display: flex; align-items: flex-end; gap: 28px; flex-wrap: wrap; margin: 16px 0; }
.vq-seg { display: inline-flex; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);