From b404420c766ce5e5199a9368f864994e4b5b9dc8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 22:08:02 -0700 Subject: [PATCH] video automations: auto-download movie + episode wishlist (soulseek) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the soulseek counterpart of the youtube drain — finally makes the people/airing scans pay off. two automations (Auto-Download Movie / Episode Wishlist, hourly): for each wished+released item, do a bounded blocking slskd search, pick the top ACCEPTED release per the quality profile, and enqueue it exactly like a manual grab (same add_video_download shape → the monitor finishes + organises it). same standard as youtube: processes the WHOLE eligible wishlist (no total cap) but searches a few at a time (max_concurrent, default 3, via a thread pool). a busy guard skips the next hourly tick while a drain is still working. movies gate on status='wanted' (skips monitored); episodes are all-wished. items already downloading are skipped. quiet skip if the library folder isn't set. reuses the real infra (build_query/slskd_search/_evaluate_hits/start_download/ download_monitor) — pure pick/select/record seam-tested; db queries added. 377 automation+video tests green. --- core/automation/blocks.py | 10 + core/automation/handlers/registration.py | 13 + .../handlers/video_process_wishlist.py | 250 ++++++++++++++++++ core/automation_engine.py | 21 ++ database/video_database.py | 26 ++ tests/automation/test_handler_registration.py | 4 + tests/test_video_process_wishlist.py | 178 +++++++++++++ webui/static/stats-automations.js | 3 + 8 files changed, 505 insertions(+) create mode 100644 core/automation/handlers/video_process_wishlist.py create mode 100644 tests/test_video_process_wishlist.py diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 728ba579..abcce3ff 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -274,6 +274,16 @@ ACTIONS: list[dict] = [ ]}, {"type": "video_scan_watchlist_playlists", "label": "Scan Watchlist Playlists", "icon": "list", "scope": "video", "description": "For every YouTube playlist you follow, wishlist its videos you don't have yet (and anything later added to it) — mirrors the whole list. Each playlist becomes its own show in the library (playlist-as-show). Shorts excluded. Pair with a schedule.", "available": True}, + {"type": "video_download_movie_wishlist", "label": "Download Movie Wishlist", "icon": "download", "scope": "video", + "description": "Auto-grab your wished, released MOVIES from Soulseek: searches each, picks the best release per your quality profile, and downloads it (the monitor finishes + organises it). Skips unreleased ('monitored') ones and any already downloading. Needs the Movie library folder + slskd set. Pair with an hourly schedule.", "available": True, + "config_fields": [ + {"key": "max_concurrent", "type": "number", "label": "Max simultaneous searches", "default": 3, "min": 1} + ]}, + {"type": "video_download_episode_wishlist", "label": "Download Episode Wishlist", "icon": "download", "scope": "video", + "description": "Auto-grab your wished EPISODES from Soulseek: searches each, picks the best release per your quality profile, and downloads it. Fed by the 'Wishlist Today's Airings' scan. Needs the TV library folder + slskd set. Pair with an hourly schedule.", "available": True, + "config_fields": [ + {"key": "max_concurrent", "type": "number", "label": "Max simultaneous searches", "default": 3, "min": 1} + ]}, {"type": "video_process_youtube_wishlist", "label": "Download YouTube Wishlist", "icon": "download", "scope": "video", "description": "Download wished YouTube videos (yt-dlp), organised as a Plex 'TV by date' show (channel/year/date). Queues the WHOLE wishlist — the setting only limits how many download at the same time; each finished one starts the next, so it all drains. A completed download leaves the wishlist. Needs the YouTube library folder set on Settings → Downloads.", "available": True, "config_fields": [ diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 86214a74..64b9011b 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -41,6 +41,7 @@ from core.automation.handlers.video_scan_watchlist_people import auto_video_scan from core.automation.handlers.video_scan_watchlist_channels import auto_video_scan_watchlist_channels from core.automation.handlers.video_process_youtube_wishlist import auto_video_process_youtube_wishlist from core.automation.handlers.video_scan_watchlist_playlists import auto_video_scan_watchlist_playlists +from core.automation.handlers.video_process_wishlist import auto_video_process_wishlist, is_running from core.automation.handlers.video_scan_library import ( auto_video_scan_library, auto_video_scan_server, auto_video_update_database, ) @@ -254,6 +255,18 @@ def register_all(deps: AutomationDeps) -> None: 'video_process_youtube_wishlist', lambda config: auto_video_process_youtube_wishlist(config, deps), ) + # Soulseek drain: auto-grab wished movies / episodes (search → pick best → download). + # The guard skips an hourly tick while a drain is still working (searches are slow). + engine.register_action_handler( + 'video_download_movie_wishlist', + lambda config: auto_video_process_wishlist(config, deps, media_type='movie'), + lambda: is_running('movie'), + ) + engine.register_action_handler( + 'video_download_episode_wishlist', + lambda config: auto_video_process_wishlist(config, deps, media_type='episode'), + lambda: is_running('episode'), + ) # Progress + history callbacks: the engine invokes these around # each handler run. Lift the closures from diff --git a/core/automation/handlers/video_process_wishlist.py b/core/automation/handlers/video_process_wishlist.py new file mode 100644 index 00000000..e89c71c4 --- /dev/null +++ b/core/automation/handlers/video_process_wishlist.py @@ -0,0 +1,250 @@ +"""Automation handlers: ``video_download_movie_wishlist`` / ``video_download_episode_wishlist``. + +The Soulseek counterpart of the YouTube wishlist drain — the piece that finally makes the +people/airing scans pay off. For wished, RELEASED movies (and aired episodes) it searches +Soulseek, picks the best release per the quality profile, and enqueues the download; the +existing ``download_monitor`` finishes + organises + archives it, exactly like a manual grab. + +Shape mirrors the YouTube drain (Boulder: "same standard"): it processes the WHOLE eligible +wishlist (no total cap), but the slow part — each item needs a ~20s blocking Soulseek search +— runs only a FEW at a time (``max_concurrent``). A ``guard`` keeps the next hourly tick from +overlapping a run that's still working, so it can't pile up. + +Movies are gated on ``status='wanted'`` (released; skips 'monitored'/unreleased). Episodes +are all-wished (the airing scan only adds aired ones). Items already downloading are skipped +so re-runs never double-grab. The pick is the top ACCEPTED release — the ranker already +encodes the quality profile's accept/reject/score, so no extra rules here. + +Shared automation side (may import ``core.video`` / ``api.video``); owns its own progress. +The search + enqueue are injected seams, so selection/pick/record are pure + unit-tested. +""" + +from __future__ import annotations + +import json +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Dict, Iterable, List, Optional + +from core.automation.deps import AutomationDeps + + +# ── pure helpers ────────────────────────────────────────────────────────────── +def pick_best(candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """The best ACCEPTED release from a ranked candidate list. ``_evaluate_hits`` already + sorts best-first (accepted, score, availability), so the first accepted is the pick.""" + for c in candidates or []: + if c.get("accepted"): + return c + return None + + +def item_key(item: Dict[str, Any], media_type: str) -> tuple: + """Stable identity for de-duping a wished item against active downloads.""" + if media_type == "movie": + return ("movie", str(item.get("tmdb_id"))) + return ("episode", str(item.get("show_tmdb_id")), + int(item.get("season_number") or 0), int(item.get("episode_number") or 0)) + + +def active_download_keys(active: Iterable[Dict[str, Any]]) -> set: + """Identity keys for the movie/episode downloads already in flight, so we don't + re-grab them. Episodes read season/episode out of the row's ``search_ctx``.""" + keys = set() + for d in active or []: + kind = str(d.get("kind") or "").lower() + if kind == "movie": + keys.add(("movie", str(d.get("media_id")))) + elif kind == "episode": + ctx = d.get("search_ctx") + if isinstance(ctx, str): + try: + ctx = json.loads(ctx) + except (ValueError, TypeError): + ctx = {} + ctx = ctx if isinstance(ctx, dict) else {} + keys.add(("episode", str(d.get("media_id")), + int(ctx.get("season") or 0), int(ctx.get("episode") or 0))) + return keys + + +def search_context(item: Dict[str, Any], media_type: str) -> Dict[str, Any]: + """The ``search_ctx`` the download row carries (drives the monitor's requery).""" + if media_type == "movie": + return {"scope": "movie", "title": item.get("title"), "year": item.get("year")} + return {"scope": "episode", "title": item.get("show_title"), + "season": item.get("season_number"), "episode": item.get("episode_number"), + "year": (str(item.get("air_date") or "")[:4] or None)} + + +def build_download_record(item: Dict[str, Any], best: Dict[str, Any], candidates: List[Dict[str, Any]], + *, media_type: str, target_dir: str, query: Any) -> Dict[str, Any]: + """The ``add_video_download`` row for a chosen release — identical shape to a manual + grab, so the monitor finishes it the same way (other accepted hits become the retry + pool).""" + ctx = search_context(item, media_type) + rest = [c for c in (candidates or []) if c.get("filename") != best.get("filename")] + media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id")) + return { + "kind": media_type, "title": ctx["title"], + "release_title": best.get("title") or best.get("filename"), + "source": "soulseek", "username": best.get("username"), "filename": best.get("filename"), + "size_bytes": int(best.get("size_bytes") or 0), "quality_label": best.get("quality_label"), + "target_dir": target_dir, "status": "downloading", + "media_id": media_id, "media_source": "tmdb", "year": ctx.get("year"), + "poster_url": item.get("poster_url"), + "candidates": json.dumps(rest), "search_ctx": json.dumps(ctx), + "tried_queries": json.dumps([query] if query else []), + "tried_files": json.dumps([best.get("filename")]), "attempts": 0, + } + + +# ── production seams ────────────────────────────────────────────────────────── +def _default_fetch_items(media_type: str) -> List[Dict[str, Any]]: + from api.video import get_video_db + db = get_video_db() + return db.movie_wishlist_to_download() if media_type == "movie" else db.episode_wishlist_to_download() + + +def _default_active_keys(media_type: str) -> set: + from api.video import get_video_db + return active_download_keys(get_video_db().get_active_video_downloads()) + + +def _default_target_dir(media_type: str) -> str: + from api.video import get_video_db + db = get_video_db() + if media_type == "movie": + return db.get_setting("movies_path") or db.get_setting("transfer_path") or "" + return db.get_setting("tv_path") or "" + + +def _default_search(item: Dict[str, Any], media_type: str) -> List[Dict[str, Any]]: + """A bounded blocking Soulseek search → ranked candidates (same path the retry worker + + manual search use). Returns [] if slskd isn't configured / nothing came back.""" + from api.video.downloads import _evaluate_hits + from core.video.download_monitor import _search_for_retry + from core.video.quality_profile import load as load_profile + from core.video.slskd_search import build_query + from api.video import get_video_db + ctx = search_context(item, media_type) + query = build_query(ctx["scope"], ctx["title"], year=ctx.get("year"), + season=ctx.get("season"), episode=ctx.get("episode")) + res = _search_for_retry(query) or {} + profile = load_profile(get_video_db()) + return _evaluate_hits(res.get("hits") or [], profile, ctx["scope"], + ctx.get("season"), ctx.get("episode")) + + +def _default_enqueue(item: Dict[str, Any], best: Dict[str, Any], candidates: List[Dict[str, Any]], + media_type: str, target_dir: str) -> bool: + """Start the slskd transfer + write the download row (exactly like the manual flow), + then ensure the monitor is running. Returns True if slskd accepted it.""" + from api.video import get_video_db + from core.video.download_monitor import ensure_started + from core.video.slskd_download import start_download + from core.video.slskd_search import build_query + started = start_download(best.get("username"), best.get("filename"), best.get("size_bytes") or 0) + if not started.get("ok"): + return False + ctx = search_context(item, media_type) + query = build_query(ctx["scope"], ctx["title"], year=ctx.get("year"), + season=ctx.get("season"), episode=ctx.get("episode")) + get_video_db().add_video_download( + build_download_record(item, best, candidates, media_type=media_type, + target_dir=target_dir, query=query)) + ensure_started(get_video_db) + return True + + +# ── guard: keep an in-progress drain from overlapping the next tick ─────────── +_running: Dict[str, bool] = {"movie": False, "episode": False} + + +def is_running(media_type: str) -> bool: + return bool(_running.get(media_type)) + + +def auto_video_process_wishlist( + config: Dict[str, Any], + deps: AutomationDeps, + *, + media_type: str = "movie", + fetch_items: Optional[Callable[[str], List[Dict[str, Any]]]] = None, + active_keys: Optional[Callable[[str], Iterable]] = None, + target_dir: Optional[Callable[[str], str]] = None, + search: Optional[Callable[[Dict[str, Any], str], List[Dict[str, Any]]]] = None, + enqueue: Optional[Callable[..., bool]] = None, +) -> Dict[str, Any]: + """Auto-grab the wished movies (or episodes): search Soulseek, pick the best release, + enqueue. Processes the whole eligible wishlist, a few searches at a time. + + Returns ``{'status': 'completed', 'searched': int, 'grabbed': int, ...}``.""" + fetch_items = fetch_items or _default_fetch_items + active_keys = active_keys or _default_active_keys + target_dir = target_dir or _default_target_dir + search = search or _default_search + enqueue = enqueue or _default_enqueue + automation_id = config.get('_automation_id') + concurrency = max(1, int(config.get('max_concurrent', 3) or 3)) + label = 'movie' if media_type == 'movie' else 'episode' + + _running[media_type] = True + try: + root = target_dir(media_type) + if not root: + where = 'Movie' if media_type == 'movie' else 'TV' + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line='%s library folder not set — skipping (Settings → Downloads)' % where, + log_type='info') + return {'status': 'completed', 'searched': 0, 'grabbed': 0, + 'skipped': 'no_folder', '_manages_own_progress': True} + + deps.update_progress(automation_id, phase='Checking the wishlist…', progress=5, + log_line='Looking for wished %ss to grab' % label, log_type='info') + items = fetch_items(media_type) or [] + active = set(active_keys(media_type) or set()) + todo = [it for it in items if item_key(it, media_type) not in active] + if not todo: + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line='Nothing new to grab (%d already in flight)' % len(active), + log_type='info') + return {'status': 'completed', 'searched': 0, 'grabbed': 0, '_manages_own_progress': True} + + grabbed = [0] + searched = [0] + total = len(todo) + lock = threading.Lock() + + def _one(it): + cands = search(it, media_type) or [] + best = pick_best(cands) + ok = bool(best) and bool(enqueue(it, best, cands, media_type, root)) + name = it.get('title') or it.get('show_title') or '?' + if media_type == 'episode': + name = "%s S%02dE%02d" % (name, int(it.get('season_number') or 0), + int(it.get('episode_number') or 0)) + with lock: + searched[0] += 1 + if ok: + grabbed[0] += 1 + deps.update_progress( + automation_id, phase='Searching + grabbing…', + progress=10 + int(85 * searched[0] / max(total, 1)), + log_line=("Grabbed '%s'" % name) if ok else ("No acceptable release for '%s'" % name), + log_type='success' if ok else 'info') + + with ThreadPoolExecutor(max_workers=concurrency) as ex: + list(ex.map(_one, todo)) + + done = ('Grabbed %d %s(s) of %d searched' % (grabbed[0], label, searched[0])) if grabbed[0] \ + else ('Searched %d %s(s) — no acceptable releases found' % (searched[0], label)) + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line=done, log_type='success' if grabbed[0] else 'info') + return {'status': 'completed', 'searched': searched[0], 'grabbed': grabbed[0], + '_manages_own_progress': True} + except Exception as e: # noqa: BLE001 + deps.update_progress(automation_id, status='error', phase='Error', log_line=str(e), log_type='error') + return {'status': 'error', 'error': str(e), '_manages_own_progress': True} + finally: + _running[media_type] = False diff --git a/core/automation_engine.py b/core/automation_engine.py index f4f83e06..49b965c5 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -279,6 +279,27 @@ SYSTEM_AUTOMATIONS = [ 'initial_delay': 1500, 'owned_by': 'video', }, + # Soulseek drains: auto-grab wished movies / episodes hourly (search → pick best → + # download). A guard skips the tick while a drain is still working. Skip quietly until + # the library folder + slskd are set. + { + 'name': 'Auto-Download Movie Wishlist', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + 'action_type': 'video_download_movie_wishlist', + 'action_config': {'max_concurrent': 3}, + 'initial_delay': 1620, + 'owned_by': 'video', + }, + { + 'name': 'Auto-Download Episode Wishlist', + 'trigger_type': 'schedule', + 'trigger_config': {'interval': 1, 'unit': 'hours'}, + 'action_type': 'video_download_episode_wishlist', + 'action_config': {'max_concurrent': 3}, + 'initial_delay': 1740, + 'owned_by': 'video', + }, ] diff --git a/database/video_database.py b/database/video_database.py index dce1afe4..c5c2309e 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -2465,6 +2465,32 @@ class VideoDatabase: finally: conn.close() + def movie_wishlist_to_download(self) -> list: + """Wished movies ready to grab: only ``status='wanted'`` (released) — skips + 'monitored' (unreleased) and anything the engine already advanced + (searching/downloading/downloaded/failed). Newest year first.""" + conn = self._get_connection() + try: + return [dict(r) for r in conn.execute( + "SELECT tmdb_id, title, year, poster_url FROM video_wishlist " + "WHERE kind='movie' AND status='wanted' AND tmdb_id IS NOT NULL " + "ORDER BY year DESC, id DESC")] + finally: + conn.close() + + def episode_wishlist_to_download(self) -> list: + """Wished episodes ready to grab (all of them — the airing scan only wishlists + episodes that have aired). Newest air date first.""" + conn = self._get_connection() + try: + return [dict(r) for r in conn.execute( + "SELECT tmdb_id AS show_tmdb_id, title AS show_title, season_number, " + "episode_number, episode_title, air_date, poster_url, library_id " + "FROM video_wishlist WHERE kind='episode' AND tmdb_id IS NOT NULL " + "ORDER BY air_date DESC, id DESC")] + finally: + conn.close() + def add_episodes_to_wishlist(self, show_tmdb_id, show_title, episodes, *, poster_url=None, library_id=None, server_source=None) -> int: """Wish for one or more episodes of a show (the show's tmdb id keys them). diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index d82d93e4..67216c81 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -64,6 +64,8 @@ EXPECTED_ACTION_NAMES = frozenset({ 'video_scan_watchlist_channels', 'video_scan_watchlist_playlists', 'video_process_youtube_wishlist', + 'video_download_movie_wishlist', + 'video_download_episode_wishlist', 'video_clean_search_history', 'video_clean_completed_downloads', 'video_full_cleanup', @@ -81,6 +83,8 @@ EXPECTED_GUARDED_ACTIONS = frozenset({ 'deep_scan_library', 'run_duplicate_cleaner', 'start_quality_scan', + 'video_download_movie_wishlist', + 'video_download_episode_wishlist', }) diff --git a/tests/test_video_process_wishlist.py b/tests/test_video_process_wishlist.py new file mode 100644 index 00000000..ba07748d --- /dev/null +++ b/tests/test_video_process_wishlist.py @@ -0,0 +1,178 @@ +"""Soulseek auto-grab for the movie + episode wishlist: search → pick best accepted release +→ enqueue (the monitor finishes it). Pure pick/select/record + the handler with the slow +slskd search + enqueue injected, plus the DB queries that feed it. +""" + +from __future__ import annotations + +import json + +import pytest + +from core.automation.handlers.video_process_wishlist import ( + active_download_keys, + auto_video_process_wishlist, + build_download_record, + item_key, + pick_best, +) + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _cand(fn, *, accepted=True, score=10, user="u"): + return {"filename": fn, "title": fn, "username": user, "size_bytes": 1000, + "quality_label": "WEBDL-1080p", "accepted": accepted, "score": score} + + +# ── pure ────────────────────────────────────────────────────────────────────── +def test_pick_best_takes_first_accepted(): + cands = [_cand("a", accepted=False), _cand("b", accepted=True), _cand("c", accepted=True)] + assert pick_best(cands)["filename"] == "b" + + +def test_pick_best_none_when_all_rejected(): + assert pick_best([_cand("a", accepted=False), _cand("b", accepted=False)]) is None + assert pick_best([]) is None + + +def test_item_key_movie_and_episode(): + assert item_key({"tmdb_id": 5}, "movie") == ("movie", "5") + assert item_key({"show_tmdb_id": 9, "season_number": 1, "episode_number": 3}, "episode") \ + == ("episode", "9", 1, 3) + + +def test_active_download_keys_reads_ctx_for_episodes(): + active = [ + {"kind": "movie", "media_id": "5"}, + {"kind": "episode", "media_id": "9", "search_ctx": json.dumps({"season": 1, "episode": 3})}, + ] + keys = active_download_keys(active) + assert ("movie", "5") in keys and ("episode", "9", 1, 3) in keys + + +def test_build_record_movie_shape(): + item = {"tmdb_id": 5, "title": "The Matrix", "year": "1999", "poster_url": "/p.jpg"} + best = _cand("Matrix.1999.1080p.mkv") + rest = [best, _cand("other.mkv")] + rec = build_download_record(item, best, rest, media_type="movie", target_dir="/movies", query="matrix 1999") + assert rec["kind"] == "movie" and rec["title"] == "The Matrix" and rec["media_id"] == "5" + assert rec["source"] == "soulseek" and rec["status"] == "downloading" + assert rec["target_dir"] == "/movies" and rec["filename"] == "Matrix.1999.1080p.mkv" + assert json.loads(rec["search_ctx"]) == {"scope": "movie", "title": "The Matrix", "year": "1999"} + assert [c["filename"] for c in json.loads(rec["candidates"])] == ["other.mkv"] # best excluded + + +def test_build_record_episode_shape(): + item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1, + "episode_number": 3, "air_date": "2008-02-10"} + best = _cand("BrBa.S01E03.mkv") + rec = build_download_record(item, best, [best], media_type="episode", target_dir="/tv", query="q") + assert rec["kind"] == "episode" and rec["media_id"] == "9" and rec["year"] == "2008" + assert json.loads(rec["search_ctx"]) == {"scope": "episode", "title": "Breaking Bad", + "season": 1, "episode": 3, "year": "2008"} + + +# ── handler ─────────────────────────────────────────────────────────────────── +def _run(items, *, active=None, root="/movies", media_type="movie", searches=None): + enq = [] + seen = [] + + def search(item, mt): + seen.append(item_key(item, mt)) + if searches is None: + return [_cand("rel-" + str(item.get("tmdb_id") or item.get("show_tmdb_id")))] + return searches.get(item_key(item, mt), []) + + def enqueue(item, best, cands, mt, target): + enq.append((item_key(item, mt), best["filename"], target)) + return True + + deps = _Deps() + res = auto_video_process_wishlist( + {"_automation_id": "a", "max_concurrent": 2}, deps, media_type=media_type, + fetch_items=lambda mt: items, active_keys=lambda mt: set(active or set()), + target_dir=lambda mt: root, search=search, enqueue=enqueue) + return res, enq, seen, deps + + +def test_grabs_each_wished_movie(): + items = [{"tmdb_id": 1, "title": "A", "year": "2020"}, {"tmdb_id": 2, "title": "B", "year": "2021"}] + res, enq, seen, _ = _run(items) + assert res["status"] == "completed" and res["grabbed"] == 2 and res["searched"] == 2 + assert {e[0] for e in enq} == {("movie", "1"), ("movie", "2")} + assert enq[0][2] == "/movies" + + +def test_skips_items_already_downloading(): + items = [{"tmdb_id": 1, "title": "A"}, {"tmdb_id": 2, "title": "B"}] + res, enq, seen, _ = _run(items, active={("movie", "1")}) + assert {e[0] for e in enq} == {("movie", "2")} # 1 already in flight + assert ("movie", "1") not in seen # not even searched + + +def test_no_acceptable_release_grabs_nothing(): + items = [{"tmdb_id": 1, "title": "A"}] + res, enq, _, _ = _run(items, searches={("movie", "1"): [_cand("junk", accepted=False)]}) + assert res["searched"] == 1 and res["grabbed"] == 0 and enq == [] + + +def test_missing_library_folder_is_a_quiet_skip(): + res, enq, _, deps = _run([{"tmdb_id": 1, "title": "A"}], root="") + assert res["status"] == "completed" and res.get("skipped") == "no_folder" + assert enq == [] and not any(p.get("status") == "error" for p in deps.progress) + + +def test_nothing_wished_is_a_clean_noop(): + res, enq, _, _ = _run([]) + assert res["status"] == "completed" and res["grabbed"] == 0 and enq == [] + + +def test_episode_mode_keys_and_grabs(): + items = [{"show_tmdb_id": 9, "show_title": "BrBa", "season_number": 1, "episode_number": 3}] + res, enq, seen, _ = _run(items, root="/tv", media_type="episode") + assert res["grabbed"] == 1 and enq[0][0] == ("episode", "9", 1, 3) and enq[0][2] == "/tv" + + +def test_top_level_error_is_caught_and_clears_guard(): + from core.automation.handlers.video_process_wishlist import is_running + + def boom(mt): + raise RuntimeError("db down") + res = auto_video_process_wishlist({"_automation_id": "x"}, _Deps(), media_type="movie", + target_dir=lambda mt: "/m", fetch_items=boom) + assert res["status"] == "error" and "db down" in res["error"] + assert is_running("movie") is False # guard released even on error + + +# ── DB queries ──────────────────────────────────────────────────────────────── +from database.video_database import VideoDatabase # noqa: E402 + + +@pytest.fixture() +def db(tmp_path): + return VideoDatabase(database_path=str(tmp_path / "video_library.db")) + + +def test_movie_wishlist_to_download_only_wanted(db): + db.add_movie_to_wishlist(1, "Released", year="2020", status="wanted") + db.add_movie_to_wishlist(2, "Upcoming", year="2027", status="monitored") + rows = db.movie_wishlist_to_download() + assert [r["tmdb_id"] for r in rows] == [1] # monitored is skipped + + +def test_episode_wishlist_to_download_shape(db): + db.add_episodes_to_wishlist(9, "Breaking Bad", [ + {"season_number": 1, "episode_number": 1, "air_date": "2008-01-20"}, + {"season_number": 1, "episode_number": 2, "air_date": "2008-01-27"}]) + rows = db.episode_wishlist_to_download() + assert len(rows) == 2 + top = rows[0] + assert top["show_tmdb_id"] == 9 and top["show_title"] == "Breaking Bad" + assert top["season_number"] == 1 and top["episode_number"] == 2 # newest air date first diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 38e571b4..f6a92a49 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1778,6 +1778,7 @@ const _autoIcons = { video_add_airing_episodes: '\uD83D\uDCFA', video_deep_scan_movies: '\uD83C\uDFAC', video_deep_scan_tv: '\uD83D\uDCFA', video_scan_watchlist_people: '\uD83C\uDFAD', video_scan_watchlist_channels: '\uD83D\uDCE1', video_scan_watchlist_playlists: '\uD83C\uDFB5', video_process_youtube_wishlist: '\u2B07\uFE0F', + video_download_movie_wishlist: '\uD83C\uDFAC', video_download_episode_wishlist: '\uD83D\uDCFA', }; // --- Inspiration Templates --- @@ -3376,6 +3377,8 @@ function _autoFormatAction(type) { video_scan_watchlist_channels: 'Scan Watchlist Channels', video_scan_watchlist_playlists: 'Scan Watchlist Playlists', video_process_youtube_wishlist: 'Download YouTube Wishlist', + video_download_movie_wishlist: 'Download Movie Wishlist', + video_download_episode_wishlist: 'Download Episode Wishlist', }; return labels[type] || type || 'Unknown'; }