From ddde6cba68d13498e99c42ac1ab2b1fd58f42823 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 10:45:38 -0700 Subject: [PATCH] video downloads: separate YouTube quality profile (yt-dlp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boulder: 'youtube should have its own quality profile — there are not many options.' Right — YouTube is grabbed with yt-dlp, not scene/p2p releases, so the Radarr-style ladder/cutoff/rejects/HDR-audio tiers are meaningless. Added a small, separate profile: - core/video/youtube_quality.py (pure, isolated): max_resolution ceiling (best…360p), video_codec (any/av1/vp9/h264, soft), container (mp4/mkv/webm), prefer_60fps, allow_hdr. normalize/load/save → video.db youtube_quality_profile. - api/video/downloads.py: GET/POST /downloads/youtube-quality. - UI: a 'YouTube Quality' card on the Downloads tab (data-video-only) — resolution dropdown + codec/container segmented + 60fps/HDR checks, reusing the vq-* styles. Wired into onPageShown + the save-all chain. The (later-phase) yt-dlp downloader maps these to a format/format_sort selection. 9 new tests green, isolation guard green, ruff clean, JS/HTML balance clean. --- api/video/downloads.py | 14 +++++ core/video/youtube_quality.py | 78 +++++++++++++++++++++++++++ tests/test_video_api.py | 24 +++++++++ tests/test_video_youtube_quality.py | 81 ++++++++++++++++++++++++++++ webui/index.html | 52 ++++++++++++++++++ webui/static/video/video-settings.js | 55 ++++++++++++++++++- 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 core/video/youtube_quality.py create mode 100644 tests/test_video_youtube_quality.py diff --git a/api/video/downloads.py b/api/video/downloads.py index f507214c..0373ac5c 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -80,6 +80,20 @@ def register_routes(bp): body = request.get_json(silent=True) or {} return jsonify(save(get_video_db(), body)) + @bp.route("/downloads/youtube-quality", methods=["GET"]) + def video_youtube_quality(): + # Separate, smaller profile — YouTube is yt-dlp, not scene/p2p releases. + from . import get_video_db + from core.video.youtube_quality import load + return jsonify(load(get_video_db())) + + @bp.route("/downloads/youtube-quality", methods=["POST"]) + def video_youtube_quality_save(): + from . import get_video_db + from core.video.youtube_quality import save + body = request.get_json(silent=True) or {} + return jsonify(save(get_video_db(), body)) + @bp.route("/downloads/slskd", methods=["GET"]) def video_slskd_config(): # SHARED with music — same slskd instance. Reads the app-wide config_manager. diff --git a/core/video/youtube_quality.py b/core/video/youtube_quality.py new file mode 100644 index 00000000..0355335e --- /dev/null +++ b/core/video/youtube_quality.py @@ -0,0 +1,78 @@ +"""YouTube download quality profile — deliberately SEPARATE from the main video +quality profile (``core/video/quality_profile.py``). + +YouTube is fetched with yt-dlp, not from scene/p2p releases, so the Radarr-style +ladder (Remux / BluRay / WEB-DL, HDR/audio tiers, scene rejects) is meaningless +here. yt-dlp just picks a stream by **resolution + codec + container**, so this +profile is small: a resolution ceiling, a codec preference, an output container, +and two flags (60fps / HDR). The (later-phase) downloader maps these to a yt-dlp +``format`` / ``format_sort`` selection. + +Pure normalize/load/save (no DB, no network) so it's unit-tested in isolation. +Persisted as a JSON blob in video.db's ``video_settings['youtube_quality_profile']``. +Isolated — imports only json/typing; the music side never imports it. +""" + +from __future__ import annotations + +import json +from typing import Any + +# Resolution ceiling (yt-dlp height filter). "best" = no cap (take the top stream). +RESOLUTIONS = ("best", "4320p", "2160p", "1440p", "1080p", "720p", "480p", "360p") +CODECS = ("any", "av1", "vp9", "h264") # SOFT preference; "any" = let yt-dlp pick best +CONTAINERS = ("mp4", "mkv", "webm") # yt-dlp --merge-output-format + + +def default_profile() -> dict: + """Sensible default: 1080p ceiling, yt-dlp's best codec, mp4 (most compatible), + prefer 60fps, SDR (HDR off — washes out on non-HDR displays).""" + return { + "version": 1, + "max_resolution": "1080p", + "video_codec": "any", + "container": "mp4", + "prefer_60fps": True, + "allow_hdr": False, + } + + +def normalize(raw: Any) -> dict: + """Coerce a stored/posted profile to a valid shape, filling gaps from the + default. Unknown keys dropped; invalid values fall back. Never raises.""" + d = default_profile() + if not isinstance(raw, dict): + return d + if raw.get("max_resolution") in RESOLUTIONS: + d["max_resolution"] = raw["max_resolution"] + if raw.get("video_codec") in CODECS: + d["video_codec"] = raw["video_codec"] + if raw.get("container") in CONTAINERS: + d["container"] = raw["container"] + d["prefer_60fps"] = bool(raw.get("prefer_60fps", d["prefer_60fps"])) + d["allow_hdr"] = bool(raw.get("allow_hdr", d["allow_hdr"])) + return d + + +def load(db) -> dict: + """Read + normalize the stored profile, or the default if none/garbage.""" + raw = db.get_setting("youtube_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("youtube_quality_profile", json.dumps(prof)) + return prof + + +__all__ = [ + "RESOLUTIONS", "CODECS", "CONTAINERS", + "default_profile", "normalize", "load", "save", +] diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 737b65f0..50c97124 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -407,6 +407,30 @@ def test_quality_profile_endpoint_roundtrips(tmp_path): videoapi._video_db = None +def test_youtube_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: + # Separate, smaller yt-dlp-shaped profile (no ladder/cutoff/rejects). + d = client.get("/api/video/downloads/youtube-quality").get_json() + assert d["max_resolution"] == "1080p" and d["container"] == "mp4" + # POST normalizes + persists; bad container rejected, valid resolution kept. + out = client.post("/api/video/downloads/youtube-quality", + json={"max_resolution": "2160p", "container": "avi", + "video_codec": "av1"}).get_json() + assert out["max_resolution"] == "2160p" and out["container"] == "mp4" + assert out["video_codec"] == "av1" + assert client.get("/api/video/downloads/youtube-quality").get_json()["max_resolution"] == "2160p" + finally: + videoapi._video_db = None + + def test_slskd_config_shared_via_config_manager(tmp_path, monkeypatch): import api.video as videoapi import config.settings as cfg diff --git a/tests/test_video_youtube_quality.py b/tests/test_video_youtube_quality.py new file mode 100644 index 00000000..8b90f437 --- /dev/null +++ b/tests/test_video_youtube_quality.py @@ -0,0 +1,81 @@ +"""YouTube download quality profile — the small, yt-dlp-shaped seam (resolution +ceiling + codec + container + 60fps/HDR flags), isolated from the music side and +separate from the main Radarr-style video quality profile.""" + +from __future__ import annotations + +import json + +from core.video.youtube_quality import ( + CODECS, + CONTAINERS, + RESOLUTIONS, + default_profile, + load, + normalize, + save, +) + + +def test_default_shape(): + d = default_profile() + assert d["max_resolution"] == "1080p" and d["video_codec"] == "any" + assert d["container"] == "mp4" + assert d["prefer_60fps"] is True and d["allow_hdr"] is False + + +def test_constants(): + assert "best" in RESOLUTIONS and "2160p" in RESOLUTIONS # no-cap + 4K offered + assert CODECS == ("any", "av1", "vp9", "h264") + assert CONTAINERS == ("mp4", "mkv", "webm") + + +def test_normalize_garbage_returns_default(): + assert normalize(None) == default_profile() + assert normalize("nope") == default_profile() + assert normalize(42) == default_profile() + + +def test_normalize_validates_enums_and_coerces_flags(): + out = normalize({"max_resolution": "2160p", "video_codec": "av1", + "container": "mkv", "prefer_60fps": 0, "allow_hdr": 1}) + assert out["max_resolution"] == "2160p" and out["video_codec"] == "av1" + assert out["container"] == "mkv" + assert out["prefer_60fps"] is False and out["allow_hdr"] is True + + +def test_normalize_rejects_unknown_values(): + bad = normalize({"max_resolution": "9000p", "video_codec": "theora", "container": "avi"}) + assert bad["max_resolution"] == "1080p" # falls back to default + assert bad["video_codec"] == "any" + assert bad["container"] == "mp4" + + +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, {"max_resolution": "best", "container": "webm", "allow_hdr": True}) + assert saved["max_resolution"] == "best" and saved["container"] == "webm" + assert saved["allow_hdr"] is True + assert json.loads(db.get_setting("youtube_quality_profile"))["container"] == "webm" + assert load(db) == saved + + +def test_load_recovers_from_corrupt_json(): + db = _FakeDB() + db.set_setting("youtube_quality_profile", "{nope") + assert load(db) == default_profile() diff --git a/webui/index.html b/webui/index.html index 3d0b87cf..919db4ee 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6179,6 +6179,58 @@ + +
+

YouTube Quality

+
+ A separate, simpler profile for the channels you follow — YouTube is grabbed with yt-dlp, so it's just a resolution ceiling, a codec preference and a container. +
+
+
Max resolution the most yt-dlp will grab — it steps down to the next available below this
+ +
+
+
Preferences codec is a soft preference; container is the final muxed file
+
+
+ Codec +
+ + + + +
+
+
+ Container +
+ + + +
+
+
+ + +
+

Download Settings

diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index c2af74c0..dbc3c469 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -17,8 +17,10 @@ var CONN_URL = '/api/video/server-config'; var DOWNLOADS_URL = '/api/video/downloads/config'; var QUALITY_URL = '/api/video/downloads/quality'; + var YT_QUALITY_URL = '/api/video/downloads/youtube-quality'; var SLSKD_URL = '/api/video/downloads/slskd'; var _videoQuality = null; + var _videoYtQuality = null; // 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', @@ -581,6 +583,55 @@ }); } + // ── YouTube quality (separate, smaller yt-dlp profile) ──────────────────── + function loadYtQuality() { + fetch(YT_QUALITY_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d) { _videoYtQuality = d; renderYtQuality(); } }) + .catch(function () { /* ignore */ }); + } + + function renderYtQuality() { + var p = _videoYtQuality; + if (!p) return; + var res = document.getElementById('yq-resolution'); if (res) res.value = p.max_resolution || '1080p'; + _vqSeg('yq-codec', 'data-yq-codec', p.video_codec); + _vqSeg('yq-container', 'data-yq-container', p.container); + var fps = document.getElementById('yq-60fps'); if (fps) fps.checked = !!p.prefer_60fps; + var hdr = document.getElementById('yq-hdr'); if (hdr) hdr.checked = !!p.allow_hdr; + } + + function saveYtQuality(silent) { + if (!_videoYtQuality) return Promise.resolve(); + return fetch(YT_QUALITY_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(_videoYtQuality) + }).then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d) _videoYtQuality = d; if (!silent) toast('YouTube quality saved', 'success'); }) + .catch(function () { /* ignore */ }); + } + + function wireYtQuality() { + var seg = document.getElementById('yq-codec'); + if (!seg) return; + var card = seg.closest('.settings-group'); + if (!card || card._yqWired) return; + card._yqWired = true; + card.addEventListener('click', function (e) { + if (!_videoYtQuality) return; + var cd = e.target.closest('[data-yq-codec]'); + if (cd) { _videoYtQuality.video_codec = cd.getAttribute('data-yq-codec'); renderYtQuality(); saveYtQuality(true); return; } + var ct = e.target.closest('[data-yq-container]'); + if (ct) { _videoYtQuality.container = ct.getAttribute('data-yq-container'); renderYtQuality(); saveYtQuality(true); return; } + }); + card.addEventListener('change', function (e) { + if (!_videoYtQuality) return; + if (e.target.id === 'yq-resolution') { _videoYtQuality.max_resolution = e.target.value; saveYtQuality(true); return; } + if (e.target.id === 'yq-60fps') { _videoYtQuality.prefer_60fps = e.target.checked; saveYtQuality(true); return; } + if (e.target.id === 'yq-hdr') { _videoYtQuality.allow_hdr = e.target.checked; saveYtQuality(true); return; } + }); + } + function saveKeys(silent) { var t = document.getElementById('tmdb-api-key'); var v = document.getElementById('tvdb-api-key'); @@ -642,6 +693,8 @@ wireDownloads(); loadQuality(); wireQuality(); + loadYtQuality(); + wireYtQuality(); loadSlskd(); wireSlskd(); } @@ -714,7 +767,7 @@ e.preventDefault(); e.stopImmediatePropagation(); Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), - saveDownloads(true), saveQuality(true), saveSlskd(true)]) + saveDownloads(true), saveQuality(true), saveYtQuality(true), saveSlskd(true)]) .then(function () { toast('Settings saved', 'success'); }) .catch(function () { toast('Some settings could not be saved', 'error'); }); }, true);