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).
This commit is contained in:
parent
4d45cc614f
commit
0a0859df7a
4 changed files with 191 additions and 1 deletions
|
|
@ -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…
Reference in a new issue