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:
parent
9f38c34900
commit
ddde6cba68
6 changed files with 303 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
78
core/video/youtube_quality.py
Normal file
78
core/video/youtube_quality.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
tests/test_video_youtube_quality.py
Normal file
81
tests/test_video_youtube_quality.py
Normal 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()
|
||||
|
|
@ -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 — 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue