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

Download Source

+
+ Video downloads from slskd, torrent or usenet only. Hybrid tries each enabled source in order. +
+
+ + +
+ +

Video Download Folders

diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 58440645..def3c4d0 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -256,7 +256,12 @@ .catch(function () { /* ignore */ }); } - // ── Downloads tab: video-specific input/output folders ── + // ── Downloads tab: folders + source mode + hybrid chain ── + var VIDEO_SOURCES = ['soulseek', 'torrent', 'usenet']; + var SRC_DL_LABEL = { soulseek: 'Soulseek', torrent: 'Torrent', usenet: 'Usenet' }; + var _videoMode = 'soulseek'; + var _videoHybrid = ['soulseek']; + function loadDownloads() { fetch(DOWNLOADS_URL, { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) @@ -266,6 +271,12 @@ if (dl && d.download_path != null) dl.value = d.download_path; var tr = document.getElementById('video-transfer-path'); if (tr && d.transfer_path != null) tr.value = d.transfer_path; + _videoMode = d.download_mode || 'soulseek'; + _videoHybrid = (d.hybrid_order && d.hybrid_order.length) ? d.hybrid_order : ['soulseek']; + var ms = document.getElementById('video-download-mode'); + if (ms) ms.value = _videoMode; + renderVideoHybrid(); + updateVideoSourceUI(); }) .catch(function () { /* ignore */ }); } @@ -278,11 +289,91 @@ body: JSON.stringify({ download_path: dl ? dl.value : '', transfer_path: tr ? tr.value : '', + download_mode: _videoMode, + hybrid_order: _videoHybrid, }) }).then(function () { if (!silent) toast('Download folders saved', 'success'); }) .catch(function () { /* ignore */ }); } + // Hybrid chain: enabled sources (ordered) + disabled ones appended. No + // album-level/track-level distinction — that's a music-only concept. + function renderVideoHybrid() { + var host = document.getElementById('video-hybrid-rows'); + if (!host) return; + var enabled = _videoHybrid.filter(function (s) { return VIDEO_SOURCES.indexOf(s) >= 0; }); + var disabled = VIDEO_SOURCES.filter(function (s) { return enabled.indexOf(s) < 0; }); + var rows = enabled.map(function (s, i) { + return '
' + + '' + + '' + + '' + + '' + + '' + SRC_DL_LABEL[s] + '' + + '' + (i + 1) + '' + + '' + + '
'; + }); + rows = rows.concat(disabled.map(function (s) { + return '
' + + '' + + '' + SRC_DL_LABEL[s] + '' + + '' + + '' + + '
'; + })); + host.innerHTML = rows.join(''); + } + + function moveVH(s, dir) { + var i = _videoHybrid.indexOf(s), j = i + dir; + if (i < 0 || j < 0 || j >= _videoHybrid.length) return; + _videoHybrid[i] = _videoHybrid[j]; _videoHybrid[j] = s; + renderVideoHybrid(); saveDownloads(true); + } + + function toggleVH(s, on) { + if (on) { + if (_videoHybrid.indexOf(s) < 0) _videoHybrid.push(s); + } else { + if (_videoHybrid.length <= 1) { renderVideoHybrid(); return; } // keep at least one + _videoHybrid = _videoHybrid.filter(function (x) { return x !== s; }); + } + renderVideoHybrid(); saveDownloads(true); + } + + function updateVideoSourceUI() { + var hc = document.getElementById('video-hybrid-container'); + if (hc) hc.style.display = _videoMode === 'hybrid' ? 'block' : 'none'; + } + + function wireDownloads() { + var ms = document.getElementById('video-download-mode'); + if (ms && !ms._vdWired) { + ms._vdWired = true; + ms.addEventListener('change', function () { + _videoMode = ms.value; updateVideoSourceUI(); saveDownloads(true); + }); + } + var host = document.getElementById('video-hybrid-rows'); + if (host && !host._vdWired) { + host._vdWired = true; + host.addEventListener('click', function (e) { + var mv = e.target.closest('[data-vh-move]'); + if (mv) moveVH(mv.getAttribute('data-vh-move'), parseInt(mv.getAttribute('data-dir'), 10)); + }); + host.addEventListener('change', function (e) { + var tg = e.target.closest('[data-vh-toggle]'); + if (tg) toggleVH(tg.getAttribute('data-vh-toggle'), tg.checked); + }); + } + // Folder inputs save on change too. + ['video-download-path', 'video-transfer-path'].forEach(function (id) { + var el = document.getElementById(id); + if (el && !el._vdWired) { el._vdWired = true; el.addEventListener('change', function () { saveDownloads(true); }); } + }); + } + // ── Video quality profile (resolution tiers + source/codec/HDR/size) ── function loadQuality() { fetch(QUALITY_URL, { headers: { 'Accept': 'application/json' } }) @@ -458,6 +549,7 @@ load(); loadKeys(); loadDownloads(); + wireDownloads(); loadQuality(); wireQuality(); }