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:
parent
887ee01cb2
commit
4d45cc614f
6 changed files with 255 additions and 6 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
62
core/video/download_config.py
Normal file
62
core/video/download_config.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
68
tests/test_video_download_config.py
Normal file
68
tests/test_video_download_config.py
Normal 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"]
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue