video downloads (phase 2): unified video quality profile

One profile across slskd/torrent/usenet — video quality is resolution + source +
codec (parsed from the release name/filename), not bitrate. Best-in-class settings UI
on the video Downloads tab:
- resolution tiers (4K/1080p/720p/480p) with per-tier toggle + arrow reordering
  (priority best-first), pill toggles
- source preference (BluRay > WEB-DL > WEBRip > HDTV), reorderable
- codec segmented control (Any / x265 / x264), Prefer-HDR toggle
- max-size-per-item slider (0 = no limit) + lower-quality fallback toggle

core/video/quality_profile.py: pure default/normalize/load/save (clamps size, rejects
bad codec, dedupes source list, recovers from corrupt JSON) — stored as JSON in
video.db's video_settings['quality_profile']. GET/POST /api/video/downloads/quality.
video-settings.js renders + saves it; isolated from music (imports only json/typing).

11 tests (9 pure + 2 API). Next: video source-mode + hybrid, then the shared slskd block.
This commit is contained in:
BoulderBadgeDad 2026-06-19 00:11:37 -07:00
parent 8392de9207
commit 887ee01cb2
7 changed files with 462 additions and 1 deletions

View file

@ -44,3 +44,16 @@ def register_routes(bp):
if key in body:
db.set_setting(key, (str(body.get(key) or "")).strip())
return jsonify({"status": "saved"})
@bp.route("/downloads/quality", methods=["GET"])
def video_quality_profile():
from . import get_video_db
from core.video.quality_profile import load
return jsonify(load(get_video_db()))
@bp.route("/downloads/quality", methods=["POST"])
def video_quality_profile_save():
from . import get_video_db
from core.video.quality_profile import save
body = request.get_json(silent=True) or {}
return jsonify(save(get_video_db(), body))

View file

@ -0,0 +1,113 @@
"""Video quality profile — ONE unified profile applied to every video download
source (slskd / torrent / usenet).
Unlike the music side (where quality is bitrate density), video quality is
**resolution + source + codec**, parsed from a release title or filename. So the
profile is a small, source-agnostic ranking the (later-phase) download engine will
use to pick the best candidate:
- resolution tiers (2160p / 1080p / 720p / 480p), each enabled + priority-ordered
- a preferred source order (bluray > web-dl > webrip > hdtv)
- a codec preference (any / x265 / x264) and an HDR preference
- an optional max size cap per item, and a fallback toggle
Pure data + normalize/validate here (no DB, no network) so it's unit-tested in
isolation. Persisted as a JSON blob in video.db's ``video_settings['quality_profile']``.
This module is isolated it imports nothing from the music side.
"""
from __future__ import annotations
import json
from typing import Any
# Ordered best→worst; the UI renders these as the default priority order.
RESOLUTIONS = ("2160p", "1080p", "720p", "480p")
SOURCES = ("bluray", "web-dl", "webrip", "hdtv")
CODECS = ("any", "x265", "x264")
MAX_SIZE_CAP_GB = 200 # slider ceiling; 0 means "no cap"
def default_profile() -> dict:
"""A sensible default: 1080p/720p on, 4K off (size), SD off."""
return {
"version": 1,
"resolutions": {
"2160p": {"enabled": False, "priority": 1},
"1080p": {"enabled": True, "priority": 2},
"720p": {"enabled": True, "priority": 3},
"480p": {"enabled": False, "priority": 4},
},
"source_priority": list(SOURCES),
"codec": "any",
"prefer_hdr": False,
"max_size_gb": 0,
"fallback_enabled": True,
}
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def normalize(raw: Any) -> dict:
"""Coerce a stored/posted profile to a valid shape, filling gaps from the
default. Unknown keys are dropped; invalid values fall back. Never raises."""
d = default_profile()
if not isinstance(raw, dict):
return d
res = raw.get("resolutions")
if isinstance(res, dict):
for i, key in enumerate(RESOLUTIONS):
r = res.get(key)
if isinstance(r, dict):
d["resolutions"][key] = {
"enabled": bool(r.get("enabled", d["resolutions"][key]["enabled"])),
"priority": _coerce_int(r.get("priority"), i + 1),
}
sp = raw.get("source_priority")
if isinstance(sp, list):
clean = []
for s in sp: # keep known sources, in order, no dupes/junk
if s in SOURCES and s not in clean:
clean.append(s)
for s in SOURCES: # append any the caller dropped, canonical order
if s not in clean:
clean.append(s)
d["source_priority"] = clean
if raw.get("codec") in CODECS:
d["codec"] = raw["codec"]
d["prefer_hdr"] = bool(raw.get("prefer_hdr", d["prefer_hdr"]))
d["max_size_gb"] = min(MAX_SIZE_CAP_GB, max(0, _coerce_int(raw.get("max_size_gb"), 0)))
d["fallback_enabled"] = bool(raw.get("fallback_enabled", True))
return d
def load(db) -> dict:
"""Read + normalize the stored profile, or the default if none/garbage."""
raw = db.get_setting("quality_profile")
if raw:
try:
return normalize(json.loads(raw))
except (ValueError, TypeError):
pass
return default_profile()
def save(db, raw: Any) -> dict:
"""Normalize + persist; returns the normalized profile that was stored."""
prof = normalize(raw)
db.set_setting("quality_profile", json.dumps(prof))
return prof
__all__ = [
"RESOLUTIONS", "SOURCES", "CODECS", "MAX_SIZE_CAP_GB",
"default_profile", "normalize", "load", "save",
]

View file

@ -379,6 +379,28 @@ def test_downloads_config_save_load(tmp_path):
videoapi._video_db = None
def test_quality_profile_endpoint_roundtrips(tmp_path):
import api.video as videoapi
from database.video_database import VideoDatabase
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
videoapi._video_db = db
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
client = app.test_client()
try:
# Default profile served when unset.
d = client.get("/api/video/downloads/quality").get_json()
assert d["resolutions"]["1080p"]["enabled"] is True and d["codec"] == "any"
# POST normalizes + persists; bad codec rejected.
out = client.post("/api/video/downloads/quality",
json={"codec": "bogus", "max_size_gb": 50, "prefer_hdr": True}).get_json()
assert out["codec"] == "any" and out["max_size_gb"] == 50 and out["prefer_hdr"] is True
assert client.get("/api/video/downloads/quality").get_json()["max_size_gb"] == 50
finally:
videoapi._video_db = None
def test_video_api_imports_nothing_from_music():
base = Path(__file__).resolve().parent.parent / "api" / "video"
for py in base.glob("*.py"):

View file

@ -0,0 +1,96 @@
"""Video quality profile — the pure default/normalize/load/save seam (resolution
tiers + source/codec/HDR + size cap), isolated from the music side."""
from __future__ import annotations
import json
from core.video.quality_profile import (
CODECS,
MAX_SIZE_CAP_GB,
RESOLUTIONS,
SOURCES,
default_profile,
load,
normalize,
save,
)
def test_default_shape():
d = default_profile()
assert set(d["resolutions"]) == set(RESOLUTIONS)
assert d["resolutions"]["1080p"]["enabled"] is True
assert d["resolutions"]["2160p"]["enabled"] is False # 4K off by default (size)
assert d["source_priority"] == list(SOURCES)
assert d["codec"] == "any" and d["prefer_hdr"] is False
assert d["max_size_gb"] == 0 and d["fallback_enabled"] is True
def test_normalize_garbage_returns_default():
assert normalize(None) == default_profile()
assert normalize("nope") == default_profile()
assert normalize(123) == default_profile()
def test_normalize_fills_gaps_and_coerces():
out = normalize({
"resolutions": {"2160p": {"enabled": True, "priority": "1"}}, # str priority coerced
"codec": "x265",
"prefer_hdr": 1,
"max_size_gb": "75",
"fallback_enabled": False,
})
assert out["resolutions"]["2160p"] == {"enabled": True, "priority": 1}
assert out["resolutions"]["1080p"]["enabled"] is True # untouched tier kept from default
assert out["codec"] == "x265" and out["prefer_hdr"] is True
assert out["max_size_gb"] == 75 and out["fallback_enabled"] is False
def test_normalize_rejects_bad_codec_and_clamps_size():
assert normalize({"codec": "vp9"})["codec"] == "any" # unknown codec rejected
assert normalize({"max_size_gb": -5})["max_size_gb"] == 0 # negative clamped
assert normalize({"max_size_gb": 99999})["max_size_gb"] == MAX_SIZE_CAP_GB # capped
def test_normalize_source_priority_dedupes_and_completes():
out = normalize({"source_priority": ["web-dl", "bogus", "web-dl"]})
# known one first, rest appended in canonical order, no dupes, no junk
assert out["source_priority"][0] == "web-dl"
assert set(out["source_priority"]) == set(SOURCES)
assert len(out["source_priority"]) == len(SOURCES)
# ── DB round-trip via an injected fake ────────────────────────────────────────
class _FakeDB:
def __init__(self):
self._kv = {}
def get_setting(self, key, default=None):
return self._kv.get(key, default)
def set_setting(self, key, value):
self._kv[key] = value
def test_load_default_when_unset():
assert load(_FakeDB()) == default_profile()
def test_save_then_load_roundtrips_normalized():
db = _FakeDB()
saved = save(db, {"codec": "x264", "max_size_gb": 40,
"resolutions": {"480p": {"enabled": True, "priority": 4}}})
assert saved["codec"] == "x264" and saved["max_size_gb"] == 40
assert json.loads(db.get_setting("quality_profile"))["codec"] == "x264"
assert load(db) == saved
def test_load_recovers_from_corrupt_json():
db = _FakeDB()
db.set_setting("quality_profile", "{not json")
assert load(db) == default_profile()
def test_codecs_constant():
assert CODECS == ("any", "x265", "x264")

View file

@ -6028,6 +6028,45 @@
<input type="text" id="video-transfer-path" placeholder="./VideoLibrary">
</div>
</div>
<!-- VIDEO quality profile — one unified profile across slskd/torrent/usenet -->
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>Video Quality Profile</h3>
<div class="setting-help-text">
One profile across slskd, torrent &amp; usenet — quality is read from the release name / filename. Toggle the tiers you want and order them best-first.
</div>
<div class="vq-block">
<div class="vq-label">Resolution <span class="vq-hint">order best-first with the arrows</span></div>
<div class="vq-rows" id="vq-resolution-rows"></div>
</div>
<div class="vq-block">
<div class="vq-label">Source preference</div>
<div class="vq-rows" id="vq-source-rows"></div>
</div>
<div class="vq-grid">
<div class="vq-block">
<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>
<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">
<h3>Download Settings</h3>

View file

@ -16,6 +16,10 @@
var SERVER_URL = '/api/video/server';
var CONN_URL = '/api/video/server-config';
var DOWNLOADS_URL = '/api/video/downloads/config';
var QUALITY_URL = '/api/video/downloads/quality';
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' };
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@ -279,6 +283,123 @@
.catch(function () { /* ignore */ });
}
// ── Video quality profile (resolution tiers + source/codec/HDR/size) ──
function loadQuality() {
fetch(QUALITY_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d) { _videoQuality = d; renderQuality(); } })
.catch(function () { /* ignore */ });
}
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="vq-row' + (r.enabled ? '' : ' vq-row--off') + '">' +
'<span class="vq-arrows">' +
'<button type="button" class="vq-arrow" data-vq-res-move="' + k + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + '>▲</button>' +
'<button type="button" class="vq-arrow" data-vq-res-move="' + k + '" data-dir="1"' + (i === keys.length - 1 ? ' disabled' : '') + '>▼</button>' +
'</span>' +
'<span class="vq-row-name">' + (RES_LABEL[k] || k) + '</span>' +
'<span class="vq-row-prio">' + (i + 1) + '</span>' +
'<label class="vq-toggle"><input type="checkbox" data-vq-res-toggle="' + k + '"' + (r.enabled ? ' checked' : '') + '><span class="vq-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="vq-row">' +
'<span class="vq-arrows">' +
'<button type="button" class="vq-arrow" data-vq-src-move="' + s + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + '>▲</button>' +
'<button type="button" class="vq-arrow" data-vq-src-move="' + s + '" data-dir="1"' + (i === p.source_priority.length - 1 ? ' disabled' : '') + '>▼</button>' +
'</span>' +
'<span class="vq-row-name">' + (SRC_LABEL[s] || s) + '</span>' +
'<span class="vq-row-prio">' + (i + 1) + '</span>' +
'</div>';
}).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';
}
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; });
renderQuality(); saveQuality(true);
}
function moveSrc(s, dir) {
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;
renderQuality(); saveQuality(true);
}
function saveQuality(silent) {
if (!_videoQuality) return Promise.resolve();
return fetch(QUALITY_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(_videoQuality)
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d) _videoQuality = d; if (!silent) toast('Quality profile saved', 'success'); })
.catch(function () { /* ignore */ });
}
// Delegated handlers for the quality profile (rows re-render, so delegate).
function wireQuality() {
var sec = document.getElementById('vq-resolution-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 cd = e.target.closest('[data-vq-codec]');
if (cd && _videoQuality) { _videoQuality.codec = cd.getAttribute('data-vq-codec'); renderQuality(); saveQuality(true); }
});
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; }
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';
}
});
}
function saveKeys(silent) {
var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key');
@ -337,6 +458,8 @@
load();
loadKeys();
loadDownloads();
loadQuality();
wireQuality();
}
function init() {
@ -406,7 +529,7 @@
if (!e.target.closest('#save-settings')) return;
e.preventDefault();
e.stopImmediatePropagation();
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true)])
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true), saveQuality(true)])
.then(function () { toast('Settings saved', 'success'); })
.catch(function () { toast('Some settings could not be saved', 'error'); });
}, true);

View file

@ -2958,3 +2958,58 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vem-yt-num { display: block; font-size: 28px; font-weight: 900; color: #fff; line-height: 1; }
.vem-yt-lbl { display: block; margin-top: 7px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
color: rgba(255,255,255,0.45); }
/* =========================================================================
Video Quality Profile (Settings Downloads, video side)
========================================================================= */
.vq-block { margin: 16px 0; }
.vq-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
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); }
.vq-rows { display: flex; flex-direction: column; gap: 7px; }
.vq-row { display: flex; align-items: center; gap: 12px; padding: 9px 13px; border-radius: 11px;
background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07);
transition: background 0.15s, opacity 0.15s; }
.vq-row--off { opacity: 0.5; }
.vq-arrows { display: inline-flex; flex-direction: column; gap: 1px; }
.vq-arrow { width: 22px; height: 15px; display: grid; place-items: center; cursor: pointer;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px;
color: rgba(255,255,255,0.6); font-size: 8px; line-height: 1; padding: 0; transition: all 0.12s; }
.vq-arrow:hover:not(:disabled) { background: rgba(var(--accent-rgb),0.25); color: #fff; }
.vq-arrow:disabled { opacity: 0.25; cursor: default; }
.vq-row-name { flex: 1; font-size: 14px; font-weight: 600; color: #fff; }
.vq-row-prio { font: 700 11px/1 'JetBrains Mono', ui-monospace, monospace; color: rgba(255,255,255,0.4);
min-width: 18px; text-align: center; }
/* pill toggle */
.vq-toggle { position: relative; display: inline-block; width: 40px; height: 22px; flex: 0 0 40px; cursor: pointer; }
.vq-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.vq-toggle-track { position: absolute; inset: 0; border-radius: 999px; background: rgba(255,255,255,0.13);
transition: background 0.18s; }
.vq-toggle-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
border-radius: 50%; background: #fff; transition: transform 0.18s; }
.vq-toggle input:checked + .vq-toggle-track { background: rgb(var(--accent-rgb)); }
.vq-toggle input:checked + .vq-toggle-track::after { transform: translateX(18px); }
/* 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);
border-radius: 10px; padding: 3px; gap: 2px; }
.vq-seg button { padding: 7px 14px; border: none; background: none; border-radius: 7px; cursor: pointer;
font-size: 12.5px; font-weight: 600; color: rgba(255,255,255,0.6); transition: all 0.15s; }
.vq-seg button:hover { color: #fff; }
.vq-seg button.active { background: rgb(var(--accent-rgb)); color: #06210f; }
/* custom checkbox (matches the rematch-modal feel) */
.vq-check { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none;
color: rgba(255,255,255,0.85); font-size: 13.5px; }
.vq-check input { display: none; }
.vq-check-box { width: 20px; height: 20px; flex: 0 0 20px; border-radius: 6px;
border: 2px solid rgba(255,255,255,0.2); display: grid; place-items: center; transition: all 0.15s; }
.vq-check input:checked + .vq-check-box { background: rgb(var(--accent-rgb)); border-color: transparent; }
.vq-check input:checked + .vq-check-box::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
/* size slider */
.vq-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px;
background: rgba(255,255,255,0.12); outline: none; cursor: pointer; }
.vq-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
background: rgb(var(--accent-rgb)); cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.4); }
.vq-slider::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%;
background: rgb(var(--accent-rgb)); cursor: pointer; }