video downloads: share the INPUT folder with music; add libraries to compose

- Input/download folder is now the SAME shared config key the music side uses
  (soulseek.download_path via config_manager) — change it on either side and both
  follow, one physical download dir, simpler Docker mounts. Output libraries stay
  video-specific in video.db. ZERO music code touched (read/write the shared key only).
- docker-compose: documented the shared ./downloads input mount, and added the three
  video library OUTPUT mounts (./Movies→/media/movies, ./TV→/media/tv,
  ./YouTube→/media/youtube) matching the in-app placeholders.
- Test monkeypatches config_manager: asserts the input folder writes the shared
  soulseek.download_path (music sees it) and is NOT in video.db; libraries persist to
  video.db; legacy migration intact. ruff + guard + balance clean.
This commit is contained in:
BoulderBadgeDad 2026-06-19 14:40:22 -07:00
parent 89bedaf140
commit 84fac1f80a
4 changed files with 55 additions and 17 deletions

View file

@ -6,13 +6,15 @@ never share a folder or collide. The actual download fulfillment engine (wishlis
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 (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.)
Folders:
- INPUT (download) folder is SHARED with the music side it's the same
``config_manager`` key the music Download Settings use (``soulseek.download_path``),
so changing it on either side changes both (one physical download dir, simpler
Docker mounts). We only READ/WRITE that shared key; no music code is touched.
- OUTPUT (library) folders are video-specific and live in video.db, one per type:
``movies_path`` / ``tv_path`` / ``youtube_path``.
The engine routes a finished download to the library path matching its type. (Legacy
single video ``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
@ -28,9 +30,11 @@ from utils.logging_config import get_logger
logger = get_logger("video_api.downloads")
# 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")
# Video-specific OUTPUT library folders (video.db). The INPUT folder is the shared
# music key below — not in this list.
_PATH_KEYS = ("movies_path", "tv_path", "youtube_path")
# The shared input/download dir — the SAME config key the music side reads/writes.
_SHARED_DOWNLOAD_KEY = "soulseek.download_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.
@ -55,10 +59,12 @@ def register_routes(bp):
def video_downloads_config():
from . import get_video_db
from core.video.download_config import load as load_source
from config.settings import config_manager
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["download_path"] = config_manager.get(_SHARED_DOWNLOAD_KEY, "") or "" # shared w/ music
out.update(load_source(db)) # download_mode + hybrid_order
return jsonify(out)
@ -71,6 +77,9 @@ def register_routes(bp):
for key in _PATH_KEYS:
if key in body:
db.set_setting(key, (str(body.get(key) or "")).strip())
if "download_path" in body: # SHARED with music — write the same config key
from config.settings import config_manager
config_manager.set(_SHARED_DOWNLOAD_KEY, (str(body.get("download_path") or "")).strip())
save_source(db, body) # download_mode + hybrid_order (validated)
return jsonify({"status": "saved"})

View file

@ -32,10 +32,20 @@ services:
# Persistent data volumes
- ./config:/app/config
- ./logs:/app/logs
# Shared INPUT folder — slskd / torrent / usenet drop downloads here, and BOTH
# the music and video sides read from it (Settings → Downloads uses one path).
- ./downloads:/app/downloads
- ./Staging:/app/Staging
- ./MusicVideos:/app/MusicVideos
- ./scripts:/app/scripts
# ─── Video libraries (OUTPUT) — one per content type ─────────────────
# The video Downloads tab sorts finished files into these by type. Point each
# at where Plex/Jellyfin reads from, then set the matching path on
# Settings → Downloads (the in-app placeholders match the in-container paths):
# Movies → /media/movies · TV → /media/tv · YouTube → /media/youtube
- ./Movies:/media/movies
- ./TV:/media/tv
- ./YouTube:/media/youtube
# Use named volume for database persistence (separate from host database)
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
- soulsync_database:/app/data

View file

@ -356,21 +356,35 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
videoapi._video_db = None
def test_downloads_config_save_load(tmp_path):
def test_downloads_config_save_load(tmp_path, monkeypatch):
import api.video as videoapi
import config.settings as cfg
from database.video_database import VideoDatabase
class _Cfg: # fake shared app config so the test never touches real music config
def __init__(self):
self._d = {}
def get(self, key, default=None):
return self._d.get(key, default)
def set(self, key, value):
self._d[key] = value
fake = _Cfg()
monkeypatch.setattr(cfg, "config_manager", fake, raising=False)
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: empty folders, soulseek mode. One shared input + per-type libraries.
# Defaults: empty folders, soulseek mode. Shared input + per-type libraries.
assert client.get("/api/video/downloads/config").get_json() == {
"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).
# Round-trips: libraries → video.db, the INPUT folder → the SHARED music key.
client.post("/api/video/downloads/config",
json={"download_path": " /mnt/v/dl ", "movies_path": "/media/movies",
"tv_path": "/media/tv", "youtube_path": "/media/yt",
@ -379,7 +393,12 @@ def test_downloads_config_save_load(tmp_path):
"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"
# The input folder is the SHARED soulseek.download_path (so music sees it too);
# it is NOT stored in video.db.
assert fake.get("soulseek.download_path") == "/mnt/v/dl"
assert db.get_setting("download_path") is None
# Library paths DO persist to video.db.
assert db.get_setting("movies_path") == "/media/movies"
# Legacy single transfer_path migrates into Movies when movies_path is unset.
db.set_setting("movies_path", "")
db.set_setting("transfer_path", "/old/lib")

View file

@ -6020,9 +6020,9 @@
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 — 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>
<label>Download Folder (Input — shared with Music):</label>
<input type="text" id="video-download-path" placeholder="./downloads">
<div class="setting-help-text">Where downloads land — point this at the same folder as your slskd / torrent client. This is the SAME folder as the Music side; changing it here changes it there too.</div>
</div>
<div class="form-group">
<label>🎬 Movies Library:</label>