video downloads: separate YouTube quality profile (yt-dlp)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-19 10:45:38 -07:00
parent 9f38c34900
commit ddde6cba68
6 changed files with 303 additions and 1 deletions

View file

@ -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.

View file

@ -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",
]

View file

@ -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

View file

@ -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()

View file

@ -6179,6 +6179,58 @@
</div>
</div>
</div>
<!-- VIDEO youtube quality — separate, smaller yt-dlp profile -->
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>YouTube Quality</h3>
<div class="setting-help-text">
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.
</div>
<div class="vq-block">
<div class="vq-label">Max resolution <span class="vq-hint">the most yt-dlp will grab &mdash; it steps down to the next available below this</span></div>
<select id="yq-resolution" class="vq-select">
<option value="best">Best available</option>
<option value="4320p">8K · 4320p</option>
<option value="2160p">4K · 2160p</option>
<option value="1440p">1440p</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
</select>
</div>
<div class="vq-block">
<div class="vq-label">Preferences <span class="vq-hint">codec is a soft preference; container is the final muxed file</span></div>
<div class="vq-prefs">
<div class="vq-pref-row">
<span class="vq-pref-name">Codec</span>
<div class="vq-seg" id="yq-codec">
<button type="button" data-yq-codec="any">Best</button>
<button type="button" data-yq-codec="av1">AV1</button>
<button type="button" data-yq-codec="vp9">VP9</button>
<button type="button" data-yq-codec="h264">H.264</button>
</div>
</div>
<div class="vq-pref-row">
<span class="vq-pref-name">Container</span>
<div class="vq-seg" id="yq-container">
<button type="button" data-yq-container="mp4">MP4</button>
<button type="button" data-yq-container="mkv">MKV</button>
<button type="button" data-yq-container="webm">WebM</button>
</div>
</div>
</div>
<label class="vq-check" style="margin-top:12px;">
<input type="checkbox" id="yq-60fps">
<span class="vq-check-box"></span>
<span>Prefer 60fps when available</span>
</label>
<label class="vq-check" style="margin-top:10px;">
<input type="checkbox" id="yq-hdr">
<span class="vq-check-box"></span>
<span>Allow HDR (off = always grab SDR)</span>
</label>
</div>
</div>
<!-- Download Settings -->
<div class="settings-group" data-stg="downloads">
<h3>Download Settings</h3>

View file

@ -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);