diff --git a/api/video/downloads.py b/api/video/downloads.py index 2f91475b..a825936d 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -32,17 +32,22 @@ def register_routes(bp): @bp.route("/downloads/config", methods=["GET"]) def video_downloads_config(): from . import get_video_db + from core.video.download_config import load as load_source db = get_video_db() - return jsonify({k: db.get_setting(k) or "" for k in _PATH_KEYS}) + out = {k: db.get_setting(k) or "" for k in _PATH_KEYS} + out.update(load_source(db)) # download_mode + hybrid_order + return jsonify(out) @bp.route("/downloads/config", methods=["POST"]) def video_downloads_config_save(): from . import get_video_db + from core.video.download_config import save as save_source db = get_video_db() body = request.get_json(silent=True) or {} for key in _PATH_KEYS: if key in body: db.set_setting(key, (str(body.get(key) or "")).strip()) + save_source(db, body) # download_mode + hybrid_order (validated) return jsonify({"status": "saved"}) @bp.route("/downloads/quality", methods=["GET"]) diff --git a/core/video/download_config.py b/core/video/download_config.py new file mode 100644 index 00000000..7ae5dd8e --- /dev/null +++ b/core/video/download_config.py @@ -0,0 +1,62 @@ +"""Video download SOURCE config — which source(s) to download from. + +Video only ever uses three sources: **soulseek / torrent / usenet** (no streaming +APIs — those are music-only). ``download_mode`` is one of those three, or +``hybrid``; in hybrid mode ``hybrid_order`` is the ordered chain of enabled sources +the (later-phase) engine tries in turn. + +Pure normalize here (no DB, no network) so it's unit-tested in isolation. Stored in +video.db's ``video_settings`` (``download_mode`` + ``hybrid_order`` JSON). Isolated +from the music side — imports only json/typing. +""" + +from __future__ import annotations + +import json +from typing import Any + +SOURCES = ("soulseek", "torrent", "usenet") +MODES = SOURCES + ("hybrid",) + + +def normalize_mode(value: Any) -> str: + v = str(value or "").strip().lower() + return v if v in MODES else "soulseek" + + +def normalize_hybrid_order(value: Any) -> list: + """Ordered, de-duped list of valid sources; defaults to ['soulseek']. Accepts a + JSON string (as stored) or a list (as posted).""" + arr = value + if isinstance(arr, str): + try: + arr = json.loads(arr) + except (ValueError, TypeError): + arr = None + out = [] + if isinstance(arr, list): + for s in arr: + s = str(s or "").strip().lower() + if s in SOURCES and s not in out: + out.append(s) + return out or ["soulseek"] + + +def load(db) -> dict: + return { + "download_mode": normalize_mode(db.get_setting("download_mode")), + "hybrid_order": normalize_hybrid_order(db.get_setting("hybrid_order")), + } + + +def save(db, body: Any) -> dict: + """Persist whichever of mode/hybrid_order is present in ``body``.""" + body = body if isinstance(body, dict) else {} + if "download_mode" in body: + db.set_setting("download_mode", normalize_mode(body.get("download_mode"))) + if "hybrid_order" in body: + db.set_setting("hybrid_order", json.dumps(normalize_hybrid_order(body.get("hybrid_order")))) + return load(db) + + +__all__ = ["SOURCES", "MODES", "normalize_mode", "normalize_hybrid_order", "load", "save"] diff --git a/tests/test_video_api.py b/tests/test_video_api.py index f9871455..1275509e 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -366,14 +366,17 @@ def test_downloads_config_save_load(tmp_path): app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") client = app.test_client() try: - # Defaults are empty. + # Defaults: empty folders, soulseek mode. assert client.get("/api/video/downloads/config").get_json() == { - "download_path": "", "transfer_path": ""} + "download_path": "", "transfer_path": "", + "download_mode": "soulseek", "hybrid_order": ["soulseek"]} # Round-trips + persists to video.db (separate from any music config). client.post("/api/video/downloads/config", - json={"download_path": " /mnt/v/dl ", "transfer_path": "/mnt/v/lib"}) + json={"download_path": " /mnt/v/dl ", "transfer_path": "/mnt/v/lib", + "download_mode": "hybrid", "hybrid_order": ["torrent", "usenet"]}) assert client.get("/api/video/downloads/config").get_json() == { - "download_path": "/mnt/v/dl", "transfer_path": "/mnt/v/lib"} # trimmed + "download_path": "/mnt/v/dl", "transfer_path": "/mnt/v/lib", # trimmed + "download_mode": "hybrid", "hybrid_order": ["torrent", "usenet"]} assert db.get_setting("download_path") == "/mnt/v/dl" finally: videoapi._video_db = None diff --git a/tests/test_video_download_config.py b/tests/test_video_download_config.py new file mode 100644 index 00000000..89c31358 --- /dev/null +++ b/tests/test_video_download_config.py @@ -0,0 +1,68 @@ +"""Video download source-config — pure normalize for mode + hybrid chain +(soulseek/torrent/usenet only), isolated from music.""" + +from __future__ import annotations + +import json + +from core.video.download_config import ( + MODES, + SOURCES, + load, + normalize_hybrid_order, + normalize_mode, + save, +) + + +def test_modes_are_video_only(): + assert SOURCES == ("soulseek", "torrent", "usenet") + assert MODES == ("soulseek", "torrent", "usenet", "hybrid") + + +def test_normalize_mode(): + assert normalize_mode("torrent") == "torrent" + assert normalize_mode("HYBRID") == "hybrid" + assert normalize_mode("spotify") == "soulseek" # music sources rejected + assert normalize_mode(None) == "soulseek" + assert normalize_mode("") == "soulseek" + + +def test_normalize_hybrid_order_filters_dedupes_defaults(): + assert normalize_hybrid_order(["torrent", "usenet"]) == ["torrent", "usenet"] + assert normalize_hybrid_order(["torrent", "torrent", "spotify"]) == ["torrent"] + assert normalize_hybrid_order([]) == ["soulseek"] # never empty + assert normalize_hybrid_order("garbage") == ["soulseek"] + # Accepts a JSON string (as stored in the KV table). + assert normalize_hybrid_order(json.dumps(["usenet", "soulseek"])) == ["usenet", "soulseek"] + + +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_defaults(): + assert load(_FakeDB()) == {"download_mode": "soulseek", "hybrid_order": ["soulseek"]} + + +def test_save_validates_and_roundtrips(): + db = _FakeDB() + out = save(db, {"download_mode": "hybrid", "hybrid_order": ["torrent", "bogus", "torrent", "usenet"]}) + assert out == {"download_mode": "hybrid", "hybrid_order": ["torrent", "usenet"]} + assert load(db) == out # persisted + reloads identically + + +def test_save_ignores_absent_keys(): + db = _FakeDB() + save(db, {"download_mode": "usenet"}) + assert load(db)["download_mode"] == "usenet" + save(db, {"hybrid_order": ["soulseek", "torrent"]}) # mode key absent → unchanged + assert load(db)["download_mode"] == "usenet" + assert load(db)["hybrid_order"] == ["soulseek", "torrent"] diff --git a/webui/index.html b/webui/index.html index 5ce464d0..0258a4e2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6014,6 +6014,25 @@ +