Phase 2 — the engine: - core/video/download_monitor.py: a daemon thread polls slskd for active video downloads, updates progress, and on completion MOVES the file from the shared download folder into the per-type library folder + marks it completed. The per-download decision (process_download) is pure (fs + slskd injected) — 6 tests. - POST /downloads/grab: validates (Soulseek-only v1), resolves the target library by kind, starts the slskd download, records the row, lazily starts the monitor. - GET /downloads/active (list + ensure monitor running) · POST /downloads/clear (drop finished). 14 tests, isolation guard + ruff clean. Grab button + Downloads page next.
109 lines
4 KiB
Python
109 lines
4 KiB
Python
"""Background monitor that drives video downloads to completion.
|
|
|
|
A daemon thread polls slskd for the active video downloads, updates their progress,
|
|
and when one finishes MOVES the file from the shared download folder into the right
|
|
per-type library folder (Movies / TV / YouTube) and marks it completed. Simple v1:
|
|
slskd source only, flat move by basename.
|
|
|
|
The per-download decision (``process_download``) is pure — filesystem + slskd are
|
|
injected — so it's unit-tested; the thread loop is thin glue.
|
|
|
|
Isolated: stdlib + the sibling video modules + shared config_manager; no music imports.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import threading
|
|
import time
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
from core.video.download_pipeline import dest_path_for, find_completed_file
|
|
from core.video.slskd_download import classify_state, find_transfer, list_downloads, progress_pct
|
|
|
|
logger = get_logger("video.download_monitor")
|
|
|
|
_INTERVAL = 3 # seconds between polls
|
|
_started = False
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def process_download(dl: dict, transfers: list, download_dir: str, *, lister, mover) -> dict | None:
|
|
"""Decide the next state for one active download given the current slskd transfers.
|
|
Returns a patch dict for the DB row (or None to leave it untouched this tick)."""
|
|
t = find_transfer(transfers, dl.get("username"), dl.get("filename"))
|
|
if not t:
|
|
return None # not registered yet (or cleared) — wait
|
|
state = classify_state(t.get("state"))
|
|
if state == "active":
|
|
return {"status": "downloading", "progress": progress_pct(t)}
|
|
if state == "failed":
|
|
return {"status": "failed", "error": "Soulseek transfer " + str(t.get("state") or "failed")}
|
|
# completed → locate the file on disk and move it into the library folder
|
|
src = find_completed_file(download_dir, dl.get("filename"), lister)
|
|
if not src:
|
|
return {"progress": 100.0} # slskd done; file still settling — retry
|
|
dest = dest_path_for(dl.get("target_dir"), src)
|
|
try:
|
|
mover(src, dest)
|
|
except Exception as e: # noqa: BLE001 - any move failure marks the download failed
|
|
return {"status": "failed", "error": "Move failed: " + str(e)}
|
|
return {"status": "completed", "progress": 100.0, "dest_path": dest}
|
|
|
|
|
|
def _move(src: str, dest: str) -> None:
|
|
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
shutil.move(src, dest)
|
|
|
|
|
|
def _walk(root: str):
|
|
for dirpath, _dirs, files in os.walk(str(root or ".")):
|
|
for f in files:
|
|
yield os.path.join(dirpath, f)
|
|
|
|
|
|
def _tick(db) -> None:
|
|
active = db.get_active_video_downloads()
|
|
if not active:
|
|
return
|
|
from config.settings import config_manager
|
|
download_dir = str(config_manager.get("soulseek.download_path", "") or "")
|
|
transfers = list_downloads()
|
|
for dl in active:
|
|
upd = process_download(dl, transfers, download_dir, lister=_walk, mover=_move)
|
|
if not upd:
|
|
continue
|
|
if upd.get("status") in ("completed", "failed"):
|
|
upd.setdefault("completed_at", time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
try:
|
|
db.update_video_download(dl["id"], **upd)
|
|
except Exception:
|
|
logger.exception("video download %s: failed to persist update", dl.get("id"))
|
|
|
|
|
|
def _run(db_provider) -> None:
|
|
logger.info("video download monitor started")
|
|
while True:
|
|
try:
|
|
db = db_provider()
|
|
if db is not None:
|
|
_tick(db)
|
|
except Exception:
|
|
logger.exception("video download monitor tick failed")
|
|
time.sleep(_INTERVAL)
|
|
|
|
|
|
def ensure_started(db_provider) -> None:
|
|
"""Start the monitor thread once (idempotent). Called when the first grab happens."""
|
|
global _started
|
|
with _lock:
|
|
if _started:
|
|
return
|
|
_started = True
|
|
threading.Thread(target=_run, args=(db_provider,), daemon=True,
|
|
name="video-download-monitor").start()
|
|
|
|
|
|
__all__ = ["process_download", "ensure_started"]
|