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 @@
+
+
+
Soulseek (slskd) Connection shared with Music
+
+ These connect to your one slskd instance and are SHARED with the Music side — changing them here changes them for both.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);