video downloads (phase 1): video-specific download + transfer folders

Foundation for the isolated video download settings. The Downloads tab is almost
entirely music-specific, so on the video side the music download sections are hidden
(video-side.css) and a data-video-only 'Video Download Folders' section takes their
place — an input (download) and output (transfer/library) folder, stored SEPARATELY
from the music soulseek.* paths in video.db's video_settings KV table.

- api/video/downloads.py: GET/POST /api/video/downloads/config (download_path,
  transfer_path), registered in the video blueprint. Imports nothing from music.
- video-settings.js: loadDownloads/saveDownloads wired into onPageShown + the
  video save-button chain.
- The shared 'Indexers & Downloaders' tab is left untouched (identical for both).

2 tests (round-trip + the isolation guard). Quality profile, video hybrid, and the
shared slskd block are the next phases.
video
BoulderBadgeDad 1 week ago
parent 8c2f66bea9
commit 8392de9207

@ -50,6 +50,7 @@ def create_video_blueprint() -> Blueprint:
from .watchlist import register_routes as reg_watchlist
from .wishlist import register_routes as reg_wishlist
from .youtube import register_routes as reg_youtube
from .downloads import register_routes as reg_downloads
reg_dashboard(bp)
reg_scan(bp)
reg_library(bp)
@ -63,5 +64,6 @@ def create_video_blueprint() -> Blueprint:
reg_watchlist(bp)
reg_wishlist(bp)
reg_youtube(bp)
reg_downloads(bp)
return bp

@ -0,0 +1,46 @@
"""Video-side download SETTINGS (isolated).
Persists the video download configuration in video.db's ``video_settings`` KV
table fully separate from the music ``soulseek.*`` paths so the two libraries
never share a folder or collide. The actual download fulfillment engine (wishlist
search grab) is a later roadmap phase; these endpoints just store/serve the
config the Settings Downloads tab edits.
Keys persisted here (all under video.db):
- ``download_path`` : input folder a video download lands in
- ``transfer_path`` : output folder finished video files move to (video library)
Connection settings that are genuinely SHARED with music (the slskd instance, the
torrent/usenet clients, Prowlarr indexers) are NOT stored here those live in the
music config_manager and are surfaced on the shared Indexers tab + shared slskd
block (a deliberate shared boundary, since they're one physical resource).
"""
from __future__ import annotations
from flask import jsonify, request
from utils.logging_config import get_logger
logger = get_logger("video_api.downloads")
# Video-specific path keys (vs. the shared connection settings).
_PATH_KEYS = ("download_path", "transfer_path")
def register_routes(bp):
@bp.route("/downloads/config", methods=["GET"])
def video_downloads_config():
from . import get_video_db
db = get_video_db()
return jsonify({k: db.get_setting(k) or "" for k in _PATH_KEYS})
@bp.route("/downloads/config", methods=["POST"])
def video_downloads_config_save():
from . import get_video_db
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())
return jsonify({"status": "saved"})

@ -356,6 +356,29 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
videoapi._video_db = None
def test_downloads_config_save_load(tmp_path):
import api.video as videoapi
from database.video_database import VideoDatabase
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:
# Defaults are empty.
assert client.get("/api/video/downloads/config").get_json() == {
"download_path": "", "transfer_path": ""}
# 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"})
assert client.get("/api/video/downloads/config").get_json() == {
"download_path": "/mnt/v/dl", "transfer_path": "/mnt/v/lib"} # trimmed
assert db.get_setting("download_path") == "/mnt/v/dl"
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"):

@ -6011,6 +6011,23 @@
<!-- Right Column - Download Settings, Database, Metadata, Logging -->
<div class="settings-right-column">
<!-- 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>Video Download Folders</h3>
<div class="setting-help-text">
Where the video side lands downloads and where it moves finished files. These are SEPARATE from your Music paths — point them at wherever your video library lives.
</div>
<div class="form-group">
<label>Download Folder (Input):</label>
<input type="text" id="video-download-path" placeholder="./video_downloads">
</div>
<div class="form-group">
<label>Transfer Folder (Output — Video Library):</label>
<input type="text" id="video-transfer-path" placeholder="./VideoLibrary">
</div>
</div>
<!-- Download Settings -->
<div class="settings-group" data-stg="downloads">
<h3>Download Settings</h3>

@ -15,6 +15,7 @@
var CONFIG_URL = '/api/video/enrichment/config';
var SERVER_URL = '/api/video/server';
var CONN_URL = '/api/video/server-config';
var DOWNLOADS_URL = '/api/video/downloads/config';
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@ -251,6 +252,33 @@
.catch(function () { /* ignore */ });
}
// ── Downloads tab: video-specific input/output folders ──
function loadDownloads() {
fetch(DOWNLOADS_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
var dl = document.getElementById('video-download-path');
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;
})
.catch(function () { /* ignore */ });
}
function saveDownloads(silent) {
var dl = document.getElementById('video-download-path');
var tr = document.getElementById('video-transfer-path');
return fetch(DOWNLOADS_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
download_path: dl ? dl.value : '',
transfer_path: tr ? tr.value : '',
})
}).then(function () { if (!silent) toast('Download folders saved', 'success'); })
.catch(function () { /* ignore */ });
}
function saveKeys(silent) {
var t = document.getElementById('tmdb-api-key');
var v = document.getElementById('tvdb-api-key');
@ -308,6 +336,7 @@
loadConn();
load();
loadKeys();
loadDownloads();
}
function init() {
@ -377,7 +406,7 @@
if (!e.target.closest('#save-settings')) return;
e.preventDefault();
e.stopImmediatePropagation();
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true)])
Promise.all([saveConn(true), save(true), saveKeys(true), savePrefs(true), saveDownloads(true)])
.then(function () { toast('Settings saved', 'success'); })
.catch(function () { toast('Some settings could not be saved', 'error'); });
}, true);

@ -146,6 +146,13 @@ body[data-side="video"] [data-stg="library"]:not([data-video-only]) {
body[data-side="video"] .settings-group[data-stg="connections"]:not([data-video-only]) {
display: none !important;
}
/* The Downloads tab is almost entirely music-specific (streaming sources, music
quality profile, retry logic). Hide the music download sections on the video
side; the video-only ones (folders, video hybrid, video quality) replace them.
The shared "Indexers & Downloaders" tab is NOT hidden it's identical for both. */
body[data-side="video"] .settings-group[data-stg="downloads"]:not([data-video-only]) {
display: none !important;
}
/* ── Dashboard header: match music, minus the sweep animation ──────────── */
/* The header reuses music's .dashboard-header markup/CSS for an identical look;

Loading…
Cancel
Save