From 8392de9207d41e4e2fc889eef4e7b6dcefa09950 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 00:04:27 -0700 Subject: [PATCH] video downloads (phase 1): video-specific download + transfer folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/video/__init__.py | 2 ++ api/video/downloads.py | 46 ++++++++++++++++++++++++++++ tests/test_video_api.py | 23 ++++++++++++++ webui/index.html | 17 ++++++++++ webui/static/video/video-settings.js | 31 ++++++++++++++++++- webui/static/video/video-side.css | 7 +++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 api/video/downloads.py diff --git a/api/video/__init__.py b/api/video/__init__.py index 5646fc1f..76346c10 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -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 diff --git a/api/video/downloads.py b/api/video/downloads.py new file mode 100644 index 00000000..16ea10a0 --- /dev/null +++ b/api/video/downloads.py @@ -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"}) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 56a66560..b16e4050 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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"): diff --git a/webui/index.html b/webui/index.html index 5cd37145..873f3271 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6011,6 +6011,23 @@
+ +
+

Video Download Folders

+
+ 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. +
+
+ + +
+
+ + +
+

Download Settings

diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 8a133d21..20431010 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -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, '&').replace(//g, '>'); @@ -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); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 5af96a8a..c4d795cd 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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;