video downloads (phase 3): source mode + hybrid chain (soulseek/torrent/usenet only)

Video-specific Download Source section: a mode dropdown limited to Soulseek / Torrent /
Usenet / Hybrid (no streaming sources — those are music-only). In hybrid mode, a chain
builder lists the three sources with arrow-reorder + enable toggles (best-first), and
NO album-level/track-level badges (a music-only concept, per spec).

- core/video/download_config.py: pure normalize for download_mode + hybrid_order
  (validates to the 3 sources, dedupes, never empties); stored in video_settings.
- /api/video/downloads/config GET/POST now carries download_mode + hybrid_order
  alongside the folders.
- video-settings.js: dropdown + hybrid rows render/reorder/toggle, show the hybrid
  container only in hybrid mode, save on change. Reuses the .vq-row styling.

7 tests (6 pure + 1 API). Next: the shared slskd connection block (reads/writes the
music config_manager so both sides share one slskd) + confirming the shared Indexers tab.
This commit is contained in:
BoulderBadgeDad 2026-06-19 00:15:13 -07:00
parent 887ee01cb2
commit 4d45cc614f
6 changed files with 255 additions and 6 deletions

View file

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

View file

@ -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"]

View file

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

View file

@ -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"]

View file

@ -6014,6 +6014,25 @@
<!-- VIDEO Downloads (isolated; shown only on the video side). The
music download sections in this tab are hidden on the video side
via video-side.css; these data-video-only ones take their place. -->
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>Download Source</h3>
<div class="setting-help-text">
Video downloads from slskd, torrent or usenet only. Hybrid tries each enabled source in order.
</div>
<div class="form-group">
<label>Download Source:</label>
<select id="video-download-mode" class="form-select">
<option value="soulseek">Soulseek Only</option>
<option value="torrent">Torrent Only (via Prowlarr)</option>
<option value="usenet">Usenet Only (via Prowlarr)</option>
<option value="hybrid">Hybrid (try each in order)</option>
</select>
</div>
<div id="video-hybrid-container" style="display:none;">
<div class="vq-label" style="margin-top:14px;">Hybrid order <span class="vq-hint">best-first; toggle off any you don't want</span></div>
<div class="vq-rows" id="video-hybrid-rows"></div>
</div>
</div>
<div class="settings-group" data-stg="downloads" data-video-only>
<h3>Video Download Folders</h3>
<div class="setting-help-text">

View file

@ -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 '<div class="vq-row">' +
'<span class="vq-arrows">' +
'<button type="button" class="vq-arrow" data-vh-move="' + s + '" data-dir="-1"' + (i === 0 ? ' disabled' : '') + '>▲</button>' +
'<button type="button" class="vq-arrow" data-vh-move="' + s + '" data-dir="1"' + (i === enabled.length - 1 ? ' disabled' : '') + '>▼</button>' +
'</span>' +
'<span class="vq-row-name">' + SRC_DL_LABEL[s] + '</span>' +
'<span class="vq-row-prio">' + (i + 1) + '</span>' +
'<label class="vq-toggle"><input type="checkbox" data-vh-toggle="' + s + '" checked><span class="vq-toggle-track"></span></label>' +
'</div>';
});
rows = rows.concat(disabled.map(function (s) {
return '<div class="vq-row vq-row--off">' +
'<span class="vq-arrows"><button type="button" class="vq-arrow" disabled>▲</button><button type="button" class="vq-arrow" disabled>▼</button></span>' +
'<span class="vq-row-name">' + SRC_DL_LABEL[s] + '</span>' +
'<span class="vq-row-prio"></span>' +
'<label class="vq-toggle"><input type="checkbox" data-vh-toggle="' + s + '"><span class="vq-toggle-track"></span></label>' +
'</div>';
}));
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();
}