video downloads (phase 4): shared slskd connection block on the video Downloads tab

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).
video
BoulderBadgeDad 1 week ago
parent 4d45cc614f
commit 0a0859df7a

@ -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})

@ -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"):

@ -6033,6 +6033,57 @@
<div class="vq-rows" id="video-hybrid-rows"></div>
</div>
</div>
<!-- slskd connection — SHARED with Music (one slskd instance). Writes the
app-wide soulseek.* config, not video.db. Shown when soulseek is active. -->
<div class="settings-group" data-stg="downloads" data-video-only id="video-slskd-container" style="display:none;">
<h3>Soulseek (slskd) Connection <span class="vq-hint">shared with Music</span></h3>
<div class="setting-help-text">
These connect to your one slskd instance and are SHARED with the Music side — changing them here changes them for both.
</div>
<div class="form-group">
<label>slskd URL:</label>
<input type="text" id="video-slskd-url" placeholder="http://localhost:5030">
</div>
<div class="form-group">
<label>API Key:</label>
<input type="password" id="video-slskd-api-key" placeholder="slskd API Key">
</div>
<div class="form-group">
<label>Search Timeout (seconds):</label>
<input type="number" id="video-slskd-search-timeout" min="15" max="300">
<div class="setting-help-text">How long to search for results (15-300 seconds)</div>
</div>
<div class="form-group">
<label>Search Timeout Buffer (seconds):</label>
<input type="number" id="video-slskd-search-timeout-buffer" min="5" max="60">
<div class="setting-help-text">Extra time to wait for late results (5-60 seconds)</div>
</div>
<div class="form-group">
<label>Minimum Delay Between Searches (seconds):</label>
<input type="number" id="video-slskd-search-min-delay" min="0" max="60">
<div class="setting-help-text">Forces a gap between consecutive searches (0 disables).</div>
</div>
<div class="form-group">
<label>Minimum Peer Upload Speed (Mbps):</label>
<input type="number" id="video-slskd-min-peer-speed" min="0" max="50">
<div class="setting-help-text">Ignore results from peers slower than this</div>
</div>
<div class="form-group">
<label>Max Peer Queue Length:</label>
<input type="number" id="video-slskd-max-peer-queue" min="0">
<div class="setting-help-text">Skip peers with a queue longer than this (0 = no limit)</div>
</div>
<div class="form-group">
<label>Download Timeout (minutes):</label>
<input type="number" id="video-slskd-download-timeout" min="2" max="60">
<div class="setting-help-text">Abandon stuck downloads after this many minutes</div>
</div>
<label class="vq-check">
<input type="checkbox" id="video-slskd-auto-clear">
<span class="vq-check-box"></span>
<span>Auto-clear slskd search history</span>
</label>
</div>
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>Video Download Folders</h3>
<div class="setting-help-text">

@ -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);

Loading…
Cancel
Save