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