diff --git a/api/video/downloads.py b/api/video/downloads.py index 16ea10a0..2f91475b 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -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)) diff --git a/core/video/quality_profile.py b/core/video/quality_profile.py new file mode 100644 index 00000000..91741b42 --- /dev/null +++ b/core/video/quality_profile.py @@ -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", +] diff --git a/tests/test_video_api.py b/tests/test_video_api.py index b16e4050..f9871455 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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"): diff --git a/tests/test_video_quality_profile.py b/tests/test_video_quality_profile.py new file mode 100644 index 00000000..22f87979 --- /dev/null +++ b/tests/test_video_quality_profile.py @@ -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") diff --git a/webui/index.html b/webui/index.html index 873f3271..5ce464d0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6028,6 +6028,45 @@ + +
+

Video Quality Profile

+
+ One profile across slskd, torrent & usenet — quality is read from the release name / filename. Toggle the tiers you want and order them best-first. +
+
+
Resolution order best-first with the arrows
+
+
+
+
Source preference
+
+
+
+
+
Codec
+
+ + + +
+
+ +
+
+
Max size per item No limit
+ +
+ +

Download Settings

diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 20431010..58440645 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -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, '&').replace(//g, '>'); @@ -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 '
' + + '' + + '' + + '' + + '' + + '' + (RES_LABEL[k] || k) + '' + + '' + (i + 1) + '' + + '' + + '
'; + }).join(''); + } + var srcHost = document.getElementById('vq-source-rows'); + if (srcHost) { + srcHost.innerHTML = p.source_priority.map(function (s, i) { + return '
' + + '' + + '' + + '' + + '' + + '' + (SRC_LABEL[s] || s) + '' + + '' + (i + 1) + '' + + '
'; + }).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); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index c4d795cd..3bb5dee5 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }