From 0a0859df7ac4044bd6b4e07260b1a01c8a18df29 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 00:20:44 -0700 Subject: [PATCH] video downloads (phase 4): shared slskd connection block on the video Downloads tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slskd CONNECTION settings (URL, API key, search timeout + buffer, min delay, min peer speed, max peer queue, download timeout, auto-clear) are genuinely shared — one slskd instance serves both sides — so the video Downloads tab surfaces them and they read/write the app-wide config_manager soulseek.* keys, NOT video.db. Changing them on the video side changes them for Music too (labeled 'shared with Music'). Deliberately EXCLUDES the music download/transfer paths and source mode/quality — those stay video-specific (video.db, phases 1-3). The block shows only when soulseek is the active/primary source (or in the hybrid chain). The 'Indexers & Downloaders' tab is already fully shared (no video override — only data-stg='downloads' is hidden). - /api/video/downloads/slskd GET/POST over config_manager (config.settings — shared app config, not music code; the isolation test still passes). - video-settings.js loadSlskd/saveSlskd (minutes↔seconds for the timeout), gated by soulseekActive(), wired into onPageShown + the save chain. 68 video API tests green (incl. the no-music-imports guard + a shared-slskd test that asserts it writes soulseek.* but never the video paths). Completes the Downloads-tab settings batch (folders, quality profile, source/hybrid, shared slskd). --- api/video/downloads.py | 33 ++++++++++++++ tests/test_video_api.py | 40 ++++++++++++++++ webui/index.html | 51 +++++++++++++++++++++ webui/static/video/video-settings.js | 68 +++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 1 deletion(-) diff --git a/api/video/downloads.py b/api/video/downloads.py index a825936d..f507214c 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -27,6 +27,23 @@ logger = get_logger("video_api.downloads") # Video-specific path keys (vs. the shared connection settings). _PATH_KEYS = ("download_path", "transfer_path") +# slskd CONNECTION settings genuinely SHARED with music — one slskd instance serves +# both sides, so these live in the app-wide config_manager (soulseek.*), NOT video.db. +# Deliberately excludes the music download/transfer PATHS and source mode/quality — +# those are video-specific (stored in video.db). Maps the video field name -> the +# shared config key + default. (config_manager is shared app config, not music code.) +_SLSKD_KEYS = { + "slskd_url": ("soulseek.slskd_url", "http://localhost:5030"), + "api_key": ("soulseek.api_key", ""), + "search_timeout": ("soulseek.search_timeout", 60), + "search_timeout_buffer": ("soulseek.search_timeout_buffer", 15), + "search_min_delay_seconds": ("soulseek.search_min_delay_seconds", 0), + "min_peer_upload_speed": ("soulseek.min_peer_upload_speed", 0), + "max_peer_queue": ("soulseek.max_peer_queue", 0), + "download_timeout": ("soulseek.download_timeout", 600), # seconds (UI shows minutes) + "auto_clear_searches": ("soulseek.auto_clear_searches", True), +} + def register_routes(bp): @bp.route("/downloads/config", methods=["GET"]) @@ -62,3 +79,19 @@ def register_routes(bp): from core.video.quality_profile 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. + from config.settings import config_manager + return jsonify({k: config_manager.get(cfg, default) + for k, (cfg, default) in _SLSKD_KEYS.items()}) + + @bp.route("/downloads/slskd", methods=["POST"]) + def video_slskd_config_save(): + from config.settings import config_manager + body = request.get_json(silent=True) or {} + for k, (cfg, _default) in _SLSKD_KEYS.items(): + if k in body: + config_manager.set(cfg, body.get(k)) + return jsonify({"status": "saved", "shared": True}) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 1275509e..bc0f1345 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -404,6 +404,46 @@ def test_quality_profile_endpoint_roundtrips(tmp_path): 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 + from database.video_database import VideoDatabase + + class _Cfg: + def __init__(self): + self._d = {} + + def get(self, key, default=None): + return self._d.get(key, default) + + def set(self, key, value): + self._d[key] = value + + fake = _Cfg() + monkeypatch.setattr(cfg, "config_manager", fake, raising=False) + + 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: + d = client.get("/api/video/downloads/slskd").get_json() + assert d["slskd_url"] == "http://localhost:5030" and d["search_timeout"] == 60 + # Writes the SHARED soulseek.* keys (so the music side sees the same slskd). + client.post("/api/video/downloads/slskd", + json={"slskd_url": "http://nas:5030", "search_timeout": 90, + "auto_clear_searches": False}) + assert fake.get("soulseek.slskd_url") == "http://nas:5030" + assert fake.get("soulseek.search_timeout") == 90 + assert fake.get("soulseek.auto_clear_searches") is False + # NOT the video-specific paths (those live in video.db, never config_manager). + assert fake.get("soulseek.download_path") is None + assert client.get("/api/video/downloads/slskd").get_json()["slskd_url"] == "http://nas:5030" + finally: + videoapi._video_db = None + + def test_video_api_imports_nothing_from_music(): base = Path(__file__).resolve().parent.parent / "api" / "video" for py in base.glob("*.py"): diff --git a/webui/index.html b/webui/index.html index 0258a4e2..043a8231 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6033,6 +6033,57 @@
+ +

Video Download Folders

diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index def3c4d0..851c23ad 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -17,6 +17,7 @@ var CONN_URL = '/api/video/server-config'; var DOWNLOADS_URL = '/api/video/downloads/config'; var QUALITY_URL = '/api/video/downloads/quality'; + var SLSKD_URL = '/api/video/downloads/slskd'; var _videoQuality = null; var RES_LABEL = { '2160p': '4K (2160p)', '1080p': '1080p', '720p': '720p', '480p': '480p (SD)' }; var SRC_LABEL = { 'bluray': 'BluRay', 'web-dl': 'WEB-DL', 'webrip': 'WEBRip', 'hdtv': 'HDTV' }; @@ -342,9 +343,71 @@ renderVideoHybrid(); saveDownloads(true); } + function soulseekActive() { + return _videoMode === 'soulseek' || + (_videoMode === 'hybrid' && _videoHybrid.indexOf('soulseek') >= 0); + } + function updateVideoSourceUI() { var hc = document.getElementById('video-hybrid-container'); if (hc) hc.style.display = _videoMode === 'hybrid' ? 'block' : 'none'; + // slskd connection only matters when soulseek is in play. + var sc = document.getElementById('video-slskd-container'); + if (sc) sc.style.display = soulseekActive() ? 'block' : 'none'; + } + + // ── Shared slskd connection (writes the app-wide soulseek.* — affects Music too) ── + function _byId(id) { return document.getElementById(id); } + function loadSlskd() { + fetch(SLSKD_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d) return; + if (_byId('video-slskd-url')) _byId('video-slskd-url').value = d.slskd_url || ''; + if (_byId('video-slskd-api-key')) _byId('video-slskd-api-key').value = d.api_key || ''; + if (_byId('video-slskd-search-timeout')) _byId('video-slskd-search-timeout').value = d.search_timeout != null ? d.search_timeout : 60; + if (_byId('video-slskd-search-timeout-buffer')) _byId('video-slskd-search-timeout-buffer').value = d.search_timeout_buffer != null ? d.search_timeout_buffer : 15; + if (_byId('video-slskd-search-min-delay')) _byId('video-slskd-search-min-delay').value = d.search_min_delay_seconds != null ? d.search_min_delay_seconds : 0; + if (_byId('video-slskd-min-peer-speed')) _byId('video-slskd-min-peer-speed').value = d.min_peer_upload_speed != null ? d.min_peer_upload_speed : 0; + if (_byId('video-slskd-max-peer-queue')) _byId('video-slskd-max-peer-queue').value = d.max_peer_queue != null ? d.max_peer_queue : 0; + // config stores seconds; UI shows minutes. + if (_byId('video-slskd-download-timeout')) _byId('video-slskd-download-timeout').value = Math.round((d.download_timeout != null ? d.download_timeout : 600) / 60); + if (_byId('video-slskd-auto-clear')) _byId('video-slskd-auto-clear').checked = d.auto_clear_searches !== false; + }) + .catch(function () { /* ignore */ }); + } + + function _num(id, dflt) { var el = _byId(id); var v = el ? parseInt(el.value, 10) : NaN; return Number.isFinite(v) ? v : dflt; } + + function saveSlskd(silent) { + var url = _byId('video-slskd-url'); + if (!url) return Promise.resolve(); // section not in DOM + return fetch(SLSKD_URL, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + slskd_url: url.value, + api_key: _byId('video-slskd-api-key') ? _byId('video-slskd-api-key').value : '', + search_timeout: _num('video-slskd-search-timeout', 60), + search_timeout_buffer: _num('video-slskd-search-timeout-buffer', 15), + search_min_delay_seconds: _num('video-slskd-search-min-delay', 0), + min_peer_upload_speed: _num('video-slskd-min-peer-speed', 0), + max_peer_queue: _num('video-slskd-max-peer-queue', 0), + download_timeout: _num('video-slskd-download-timeout', 10) * 60, // minutes → seconds + auto_clear_searches: _byId('video-slskd-auto-clear') ? _byId('video-slskd-auto-clear').checked : true, + }) + }).then(function () { if (!silent) toast('slskd settings saved (shared with Music)', 'success'); }) + .catch(function () { /* ignore */ }); + } + + function wireSlskd() { + var ids = ['video-slskd-url', 'video-slskd-api-key', 'video-slskd-search-timeout', + 'video-slskd-search-timeout-buffer', 'video-slskd-search-min-delay', + 'video-slskd-min-peer-speed', 'video-slskd-max-peer-queue', + 'video-slskd-download-timeout', 'video-slskd-auto-clear']; + ids.forEach(function (id) { + var el = _byId(id); + if (el && !el._vsWired) { el._vsWired = true; el.addEventListener('change', function () { saveSlskd(true); }); } + }); } function wireDownloads() { @@ -552,6 +615,8 @@ wireDownloads(); loadQuality(); wireQuality(); + loadSlskd(); + wireSlskd(); } function init() { @@ -621,7 +686,8 @@ if (!e.target.closest('#save-settings')) return; e.preventDefault(); e.stopImmediatePropagation(); - Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true), saveQuality(true)]) + Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), + saveDownloads(true), saveQuality(true), saveSlskd(true)]) .then(function () { toast('Settings saved', 'success'); }) .catch(function () { toast('Some settings could not be saved', 'error'); }); }, true);