video downloads: split transfer folder into Movies / TV / YouTube libraries

One shared download (input) folder feeds three separate library (output) folders —
the engine routes a finished download to the one matching its type, so YouTube never
lands in your real TV library (own Plex/Jellyfin library + agent). Replaces the single
transfer_path with movies_path / tv_path / youtube_path (legacy transfer_path migrates
into Movies on first read). Settings UI: shared Download folder + 🎬 Movies / 📺 TV /
▶️ YouTube library inputs. Tests updated incl. the migration. ruff + balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 14:32:54 -07:00
parent 86fde7d62c
commit 89bedaf140
4 changed files with 45 additions and 21 deletions

View file

@ -7,8 +7,12 @@ never share a folder or collide. The actual download fulfillment engine (wishlis
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)
- ``download_path`` : input folder a video download lands in (shared by all types)
- ``movies_path`` : Movies library (finished movies move here)
- ``tv_path`` : TV Shows library
- ``youtube_path`` : YouTube library (kept separate from real TV)
The engine routes a finished download to the path matching its type. (Legacy single
``transfer_path`` is migrated into ``movies_path`` on first read.)
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
@ -24,8 +28,9 @@ 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")
# Video-specific path keys (vs. the shared connection settings): one shared input
# folder + a separate library folder per content type.
_PATH_KEYS = ("download_path", "movies_path", "tv_path", "youtube_path")
# slskd CONNECTION settings genuinely SHARED with music — one slskd instance serves
# both sides, so these live in the app-wide config_manager (soulseek.*), NOT video.db.
@ -52,6 +57,8 @@ def register_routes(bp):
from core.video.download_config import load as load_source
db = get_video_db()
out = {k: db.get_setting(k) or "" for k in _PATH_KEYS}
if not out["movies_path"]: # migrate the legacy single transfer folder → Movies
out["movies_path"] = db.get_setting("transfer_path") or ""
out.update(load_source(db)) # download_mode + hybrid_order
return jsonify(out)

View file

@ -366,18 +366,24 @@ 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: empty folders, soulseek mode.
# Defaults: empty folders, soulseek mode. One shared input + per-type libraries.
assert client.get("/api/video/downloads/config").get_json() == {
"download_path": "", "transfer_path": "",
"download_path": "", "movies_path": "", "tv_path": "", "youtube_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 ", "movies_path": "/media/movies",
"tv_path": "/media/tv", "youtube_path": "/media/yt",
"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", "movies_path": "/media/movies", # trimmed
"tv_path": "/media/tv", "youtube_path": "/media/yt",
"download_mode": "hybrid", "hybrid_order": ["torrent", "usenet"]}
assert db.get_setting("download_path") == "/mnt/v/dl"
# Legacy single transfer_path migrates into Movies when movies_path is unset.
db.set_setting("movies_path", "")
db.set_setting("transfer_path", "/old/lib")
assert client.get("/api/video/downloads/config").get_json()["movies_path"] == "/old/lib"
finally:
videoapi._video_db = None

View file

@ -6017,15 +6017,24 @@
<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 — SEPARATE from your Music paths.
One shared download folder (where slskd / torrent drop files), then a separate library folder per type — finished files are sorted to the right one automatically. SEPARATE from your Music paths.
</div>
<div class="form-group">
<label>Download Folder (Input):</label>
<label>Download Folder (Input — shared):</label>
<input type="text" id="video-download-path" placeholder="./video_downloads">
<div class="setting-help-text">Where downloads land. Point this at the same folder as your slskd / torrent client.</div>
</div>
<div class="form-group">
<label>Transfer Folder (Output — Video Library):</label>
<input type="text" id="video-transfer-path" placeholder="./VideoLibrary">
<label>🎬 Movies Library:</label>
<input type="text" id="video-movies-path" placeholder="/media/movies">
</div>
<div class="form-group">
<label>📺 TV Shows Library:</label>
<input type="text" id="video-tv-path" placeholder="/media/tv">
</div>
<div class="form-group">
<label>▶️ YouTube Library:</label>
<input type="text" id="video-youtube-path" placeholder="/media/youtube">
</div>
</div>
<div class="settings-group" data-stg="downloads" data-video-only>

View file

@ -281,10 +281,11 @@
.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;
var setP = function (id, v) { var el = document.getElementById(id); if (el && v != null) el.value = v; };
setP('video-download-path', d.download_path);
setP('video-movies-path', d.movies_path);
setP('video-tv-path', d.tv_path);
setP('video-youtube-path', d.youtube_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');
@ -296,13 +297,14 @@
}
function saveDownloads(silent) {
var dl = document.getElementById('video-download-path');
var tr = document.getElementById('video-transfer-path');
var val = function (id) { var el = document.getElementById(id); return el ? el.value : ''; };
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 : '',
download_path: val('video-download-path'),
movies_path: val('video-movies-path'),
tv_path: val('video-tv-path'),
youtube_path: val('video-youtube-path'),
download_mode: _videoMode,
hybrid_order: _videoHybrid,
})
@ -440,7 +442,7 @@
});
}
// Folder inputs save on change too.
['video-download-path', 'video-transfer-path'].forEach(function (id) {
['video-download-path', 'video-movies-path', 'video-tv-path', 'video-youtube-path'].forEach(function (id) {
var el = document.getElementById(id);
if (el && !el._vdWired) { el._vdWired = true; el.addEventListener('change', function () { saveDownloads(true); }); }
});