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"]) @bp.route("/downloads/config", methods=["GET"])
def video_downloads_config(): def video_downloads_config():
from . import get_video_db from . import get_video_db
from core.video.download_config import load as load_source
db = get_video_db() 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"]) @bp.route("/downloads/config", methods=["POST"])
def video_downloads_config_save(): def video_downloads_config_save():
from . import get_video_db from . import get_video_db
from core.video.download_config import save as save_source
db = get_video_db() db = get_video_db()
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
for key in _PATH_KEYS: for key in _PATH_KEYS:
if key in body: if key in body:
db.set_setting(key, (str(body.get(key) or "")).strip()) db.set_setting(key, (str(body.get(key) or "")).strip())
save_source(db, body) # download_mode + hybrid_order (validated)
return jsonify({"status": "saved"}) return jsonify({"status": "saved"})
@bp.route("/downloads/quality", methods=["GET"]) @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") app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
client = app.test_client() client = app.test_client()
try: try:
# Defaults are empty. # Defaults: empty folders, soulseek mode.
assert client.get("/api/video/downloads/config").get_json() == { 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). # Round-trips + persists to video.db (separate from any music config).
client.post("/api/video/downloads/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() == { 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" assert db.get_setting("download_path") == "/mnt/v/dl"
finally: finally:
videoapi._video_db = None 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 <!-- VIDEO Downloads (isolated; shown only on the video side). The
music download sections in this tab are hidden on the video side music download sections in this tab are hidden on the video side
via video-side.css; these data-video-only ones take their place. --> 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> <div class="settings-group" data-stg="downloads" data-video-only>
<h3>Video Download Folders</h3> <h3>Video Download Folders</h3>
<div class="setting-help-text"> <div class="setting-help-text">

View file

@ -256,7 +256,12 @@
.catch(function () { /* ignore */ }); .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() { function loadDownloads() {
fetch(DOWNLOADS_URL, { headers: { 'Accept': 'application/json' } }) fetch(DOWNLOADS_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : null; })
@ -266,6 +271,12 @@
if (dl && d.download_path != null) dl.value = d.download_path; if (dl && d.download_path != null) dl.value = d.download_path;
var tr = document.getElementById('video-transfer-path'); var tr = document.getElementById('video-transfer-path');
if (tr && d.transfer_path != null) tr.value = d.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 */ }); .catch(function () { /* ignore */ });
} }
@ -278,11 +289,91 @@
body: JSON.stringify({ body: JSON.stringify({
download_path: dl ? dl.value : '', download_path: dl ? dl.value : '',
transfer_path: tr ? tr.value : '', transfer_path: tr ? tr.value : '',
download_mode: _videoMode,
hybrid_order: _videoHybrid,
}) })
}).then(function () { if (!silent) toast('Download folders saved', 'success'); }) }).then(function () { if (!silent) toast('Download folders saved', 'success'); })
.catch(function () { /* ignore */ }); .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) ── // ── Video quality profile (resolution tiers + source/codec/HDR/size) ──
function loadQuality() { function loadQuality() {
fetch(QUALITY_URL, { headers: { 'Accept': 'application/json' } }) fetch(QUALITY_URL, { headers: { 'Accept': 'application/json' } })
@ -458,6 +549,7 @@
load(); load();
loadKeys(); loadKeys();
loadDownloads(); loadDownloads();
wireDownloads();
loadQuality(); loadQuality();
wireQuality(); wireQuality();
} }