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 '