From 31d284ab0d8bf6eacf25db4c78faf9f962ae28b2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 19:07:06 -0700 Subject: [PATCH] video automations: watchlist scans for people + channels two new video-side automation blocks that keep the wishlist fed: - scan watchlist people: for each followed person, wishlist every un-owned movie they acted in or directed (back catalog + upcoming). released -> wanted, upcoming -> monitored (engine skips it til it's out, promotes on release). grabs rich detail at add time (backdrop/cast/overview/etc + provenance) into a new video_wishlist.detail_json col. fast re-runs skip already-wishlisted + only promote. - scan watchlist channels: for each followed youtube channel, wishlist new long-form uploads (shorts excluded). forward-looking from follow time + a last-N safety net (default 10). diffs against wishlisted/downloaded/ dismissed so it never dupes. pair with a 6h schedule trigger. scan-only; fulfillment engine comes later. both are pure handlers with injected seams + full seam tests. add_movie_to_ wishlist gains status + detail_json (promote-only upsert). no schema break, music side untouched. --- core/automation/blocks.py | 7 + core/automation/handlers/registration.py | 14 + .../handlers/video_scan_watchlist_channels.py | 234 ++++++++++++ .../handlers/video_scan_watchlist_people.py | 296 +++++++++++++++ database/video_database.py | 48 ++- tests/automation/test_handler_registration.py | 2 + tests/test_video_scan_watchlist_channels.py | 197 ++++++++++ tests/test_video_scan_watchlist_people.py | 346 ++++++++++++++++++ webui/static/stats-automations.js | 3 + 9 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 core/automation/handlers/video_scan_watchlist_channels.py create mode 100644 core/automation/handlers/video_scan_watchlist_people.py create mode 100644 tests/test_video_scan_watchlist_channels.py create mode 100644 tests/test_video_scan_watchlist_people.py diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 892f62bb..306bca2f 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -265,6 +265,13 @@ ACTIONS: list[dict] = [ "config_fields": [ {"key": "prune_ended", "type": "checkbox", "label": "Also remove ended/canceled shows from the watchlist", "default": True} ]}, + {"type": "video_scan_watchlist_people", "label": "Scan Watchlist People", "icon": "users", "scope": "video", + "description": "For everyone you follow on the watchlist, wishlist every movie they acted in or directed that you don't already own — the whole back catalog plus anything upcoming (kept as 'monitored' until it's released). First run backlogs everything; later runs are fast.", "available": True}, + {"type": "video_scan_watchlist_channels", "label": "Scan Watchlist Channels", "icon": "youtube", "scope": "video", + "description": "For every YouTube channel you follow, wishlist its new long-form uploads (Shorts excluded). Forward-looking from when you followed, plus a safety net that always keeps the last N videos. Pair with a 6-hourly Schedule trigger — channels post at all hours.", "available": True, + "config_fields": [ + {"key": "backfill_count", "type": "number", "label": "Always keep the last N videos from each channel", "default": 10, "min": 0} + ]}, # Video twins of the music maintenance actions. Distinct action_type (the # system seeder keys on action_type, so a shared key would collide with the # music row) but the SAME shared handler — the cleanup operates on the common diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 46cba376..1cd30406 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -37,6 +37,8 @@ from core.automation.handlers.download_cleanup import ( from core.automation.handlers.run_script import auto_run_script from core.automation.handlers.search_and_download import auto_search_and_download from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes +from core.automation.handlers.video_scan_watchlist_people import auto_video_scan_watchlist_people +from core.automation.handlers.video_scan_watchlist_channels import auto_video_scan_watchlist_channels from core.automation.handlers.video_scan_library import ( auto_video_scan_library, auto_video_scan_server, auto_video_update_database, ) @@ -227,6 +229,18 @@ def register_all(deps: AutomationDeps) -> None: 'video_add_airing_episodes', lambda config: auto_video_add_airing_episodes(config, deps), ) + # Watchlist-people scan: wishlist every un-owned movie the followed people acted in or + # directed (back catalog + upcoming). + engine.register_action_handler( + 'video_scan_watchlist_people', + lambda config: auto_video_scan_watchlist_people(config, deps), + ) + # Watchlist-channels scan: wishlist new long-form uploads from followed YouTube + # channels (forward-looking + last-N safety net). + engine.register_action_handler( + 'video_scan_watchlist_channels', + lambda config: auto_video_scan_watchlist_channels(config, deps), + ) # Progress + history callbacks: the engine invokes these around # each handler run. Lift the closures from diff --git a/core/automation/handlers/video_scan_watchlist_channels.py b/core/automation/handlers/video_scan_watchlist_channels.py new file mode 100644 index 00000000..8e623cd3 --- /dev/null +++ b/core/automation/handlers/video_scan_watchlist_channels.py @@ -0,0 +1,234 @@ +"""Automation handler: ``video_scan_watchlist_channels`` action. + +The "what's new from the channels I follow" scan — the piece that lets SoulSync replace +ytdl-sub-style YouTube auto-downloaders. For every YouTube channel on the video watchlist, +look at its recent uploads and wishlist the new long-form videos the user doesn't already +have, so the (future) fulfillment engine can grab them. + +This runs on a SHORT schedule (channels publish at all hours — pair it with a 6-hourly +Schedule trigger; 3h is fine too, the scan is cheap). It's forward-looking and dup-proof: + + * **Baseline = follow time.** What the user had before following isn't our concern — only + uploads published on/after they followed the channel (the watchlist row's ``date_added``) + are "new" and get wishlisted, forever forward. + * **Last-N safety net.** A per-channel default (10) reaches a little BEFORE the baseline so + the user is always kept current on the most recent videos even right after following / if + a scan was missed. Global setting now; per-channel override (the hover settings modal, + like watchlist-artist settings) comes later. + * **Long-form only.** Shorts are excluded (the channel's Videos tab + a duration floor); + livestreams/premieres that haven't aired are skipped (future-dated). + * **Never duplicates.** Each candidate is diffed against what's already wishlisted, what's + already been downloaded (the permanent download-history ledger — empty for YouTube until + the fulfillment engine lands, wired here so it's correct the day it does), and what the + user has dismissed. + +Like the other video handlers it lives on the SHARED automation side (may import +``core.video`` / ``api.video`` — the isolation contract only forbids the reverse) and owns +its own progress (``_manages_own_progress``). All I/O is injected as seams, so the selection +logic is a pure, unit-testable function; production lazily binds the real calls. + +NOTE (follow-up): "remove from the YouTube wishlist" currently just deletes the row, so the +last-N net could re-add a video the user deliberately removed. The scan already respects a +``dismissed_ids`` seam — wiring the wishlist-remove to record a dismissal (once the wishlist +is a real download queue) closes that loop. Tracked, out of scope for this scan-only phase. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Callable, Dict, Iterable, List, Optional + +from core.automation.deps import AutomationDeps + + +# ── pure selection ──────────────────────────────────────────────────────────── +def is_short(video: Dict[str, Any], min_seconds: int) -> bool: + """A YouTube Short — a known duration under the floor. Unknown duration is NOT + assumed short (the Videos-tab listing already excludes Shorts; this is a backstop).""" + d = video.get("duration_seconds") + return isinstance(d, (int, float)) and 0 < d < min_seconds + + +def long_form_uploads(uploads: Iterable, min_seconds: int) -> List[Dict[str, Any]]: + """The channel's real videos, newest-first order preserved: drop Shorts + entries + with no id.""" + return [v for v in (uploads or []) + if isinstance(v, dict) and v.get("youtube_id") and not is_short(v, min_seconds)] + + +def _day(value: Any) -> Optional[str]: + s = str(value or "").strip() + return s[:10] or None + + +def select_channel_video_gaps( + uploads: List[Dict[str, Any]], + *, + baseline_date: Optional[str], + backfill_count: int, + wishlisted_ids: Iterable = (), + dismissed_ids: Iterable = (), + downloaded_ids: Iterable = (), + today: str, + min_seconds: int = 60, +) -> List[Dict[str, Any]]: + """The pure core: which of a channel's recent uploads to wishlist now. + + ``uploads`` is the channel's recent videos newest-first (rich shape: youtube_id, title, + published_at, duration_seconds, thumbnail_url, …). Keeps a long-form upload if it's not + already wishlisted / downloaded / dismissed AND either it's within the newest + ``backfill_count`` (the safety net) or it was published on/after ``baseline_date`` (the + forward-looking part). Future-dated (unaired premiere/stream) is skipped. No I/O.""" + longs = long_form_uploads(uploads, min_seconds) + n = max(0, int(backfill_count or 0)) + net_ids = {v["youtube_id"] for v in longs[:n]} + excluded = set(wishlisted_ids or ()) | set(dismissed_ids or ()) | set(downloaded_ids or ()) + + out: List[Dict[str, Any]] = [] + for v in longs: + vid = v["youtube_id"] + if vid in excluded: + continue + d = _day(v.get("published_at")) + if d and today and d > today: + continue # scheduled / unaired — not out yet + if vid in net_ids or (d and baseline_date and d >= baseline_date): + out.append(v) + return out + + +# ── production seams ────────────────────────────────────────────────────────── +def _default_fetch_channels() -> List[Dict[str, Any]]: + from api.video import get_video_db + return get_video_db().list_watchlist_channels() + + +def _default_fetch_uploads(channel_id: Any, limit: int) -> List[Dict[str, Any]]: + """A channel's recent uploads (Videos tab → long-form, with durations) with upload + dates merged on from the cache + a cheap RSS pull — the same composition the channel + detail endpoint uses. Caches any dates it learns so later scans get them free.""" + from api.video import get_video_db + from core.video import youtube as yt + db = get_video_db() + cid = str(channel_id) + channel = yt.resolve_channel("https://www.youtube.com/channel/" + cid, + limit=max(1, min(90, int(limit)))) or {} + vids = channel.get("videos") or [] + ids = [v.get("youtube_id") for v in vids if v.get("youtube_id")] + dates = db.get_video_dates(ids) or {} + try: + dates.update(yt.channel_recent_dates(cid) or {}) + except Exception: # noqa: BLE001, S110 - RSS is best-effort; cached dates still apply + pass + for v in vids: + if not v.get("published_at") and dates.get(v.get("youtube_id")): + v["published_at"] = dates[v["youtube_id"]] + try: + db.cache_video_dates([{"youtube_id": v["youtube_id"], "published_at": v.get("published_at")} + for v in vids if v.get("published_at")]) + except Exception: # noqa: BLE001, S110 - caching is opportunistic + pass + return vids + + +def _default_wishlisted_ids(channel_id: Any) -> List[Any]: + from api.video import get_video_db + return get_video_db().wishlisted_video_ids_for_channel(channel_id) + + +def _default_dismissed_ids(channel_id: Any) -> List[Any]: + # No YouTube "dismissed" store yet (video_ignored is movie/show-level). Returns empty; + # wiring wishlist-remove → dismiss is the tracked follow-up (see module docstring). + return [] + + +def _default_downloaded_ids(channel_id: Any) -> List[Any]: + # The permanent download-history ledger has no YouTube grabs until the fulfillment + # engine runs; empty for now, so the scan is correct the day those rows appear. + return [] + + +def _default_add_videos(channel: Dict[str, Any], videos: List[Dict[str, Any]]) -> int: + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().add_videos_to_wishlist(channel, videos, server_source=resolve_video_server()) + + +def auto_video_scan_watchlist_channels( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_channels: Optional[Callable[[], List[Dict[str, Any]]]] = None, + fetch_uploads: Optional[Callable[[Any, int], List[Dict[str, Any]]]] = None, + wishlisted_ids: Optional[Callable[[Any], Iterable]] = None, + dismissed_ids: Optional[Callable[[Any], Iterable]] = None, + downloaded_ids: Optional[Callable[[Any], Iterable]] = None, + add_videos: Optional[Callable[[Dict[str, Any], List[Dict[str, Any]]], int]] = None, + today_fn: Optional[Callable[[], str]] = None, +) -> Dict[str, Any]: + """Scan every followed YouTube channel and wishlist its new long-form uploads. + + Returns ``{'status': 'completed', 'channels': int, 'videos_added': int, ...}``.""" + fetch_channels = fetch_channels or _default_fetch_channels + fetch_uploads = fetch_uploads or _default_fetch_uploads + wishlisted_ids = wishlisted_ids or _default_wishlisted_ids + dismissed_ids = dismissed_ids or _default_dismissed_ids + downloaded_ids = downloaded_ids or _default_downloaded_ids + add_videos = add_videos or _default_add_videos + today_fn = today_fn or (lambda: date.today().isoformat()) + automation_id = config.get('_automation_id') + backfill = max(0, int(config.get('backfill_count', 10) or 0)) + min_seconds = max(0, int(config.get('min_seconds', 60) or 0)) + limit = max(backfill, 30) + + try: + today = today_fn() + deps.update_progress(automation_id, phase='Reading your watchlist…', progress=5, + log_line='Loading the channels you follow', log_type='info') + channels = fetch_channels() or [] + if not channels: + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line='No channels on the watchlist to scan', log_type='info') + return {'status': 'completed', 'channels': 0, 'videos_added': 0, + '_manages_own_progress': True} + + added = 0 + total = len(channels) + for i, ch in enumerate(channels): + cid = ch.get('youtube_id') + ctitle = ch.get('title') or cid + deps.update_progress(automation_id, phase='Scanning channels…', + progress=10 + int(80 * i / max(total, 1)), + log_line="Checking %s for new videos" % ctitle, log_type='info') + if not cid: + continue + try: + uploads = fetch_uploads(cid, limit) or [] + except Exception: # noqa: BLE001 - one flaky channel shouldn't abort the scan + deps.update_progress(automation_id, log_line="Couldn't reach %s — skipping" % ctitle, + log_type='warning') + continue + + baseline = _day(ch.get('date_added')) or today + gaps = select_channel_video_gaps( + uploads, baseline_date=baseline, backfill_count=backfill, + wishlisted_ids=wishlisted_ids(cid), dismissed_ids=dismissed_ids(cid), + downloaded_ids=downloaded_ids(cid), today=today, min_seconds=min_seconds) + if gaps: + n = int(add_videos({'youtube_id': cid, 'title': ctitle, + 'avatar_url': ch.get('poster_url')}, gaps) or 0) + added += n + if n: + deps.update_progress( + automation_id, log_type='success', + log_line="Wishlisted %d new video(s) from %s" % (n, ctitle)) + + done = ('Wishlisted %d new video(s) across %d channel(s)' % (added, total)) if added \ + else ('Channels are up to date — nothing new across %d channel(s)' % total) + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line=done, log_type='success') + return {'status': 'completed', 'channels': total, 'videos_added': added, + '_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} diff --git a/core/automation/handlers/video_scan_watchlist_people.py b/core/automation/handlers/video_scan_watchlist_people.py new file mode 100644 index 00000000..2d4c140f --- /dev/null +++ b/core/automation/handlers/video_scan_watchlist_people.py @@ -0,0 +1,296 @@ +"""Automation handler: ``video_scan_watchlist_people`` action. + +The video "watchlist" follows ongoing *things* — shows, channels, and people. For shows +the ``video_add_airing_episodes`` automation already keeps the wishlist fed. This handler +does the equivalent for the PEOPLE you follow: + +For every person on the watchlist, look up their filmography and wishlist every MOVIE the +user doesn't already own — the whole back catalog they acted in or directed, plus anything +upcoming. The first run is the meaty one (it backlogs everything); later runs are fast +because they skip movies already on the wishlist and only promote ones that have since been +released. + +Design decisions (Boulder): + * MOVIES ONLY — no TV episodes for a person (shows are handled by their own automation). + * Both ACTOR (Acting credits, minus "playing themselves") and DIRECTOR credits count. + * UNRELEASED movies are added as ``status='monitored'`` so the wishlist/download engine + leaves them alone until they're out; a later scan PROMOTES them to ``'wanted'``. + * Best-in-class UX: grab as much data as possible at add time (backdrop, overview, genres, + runtime, rating, top cast, director, release date, + provenance "because you follow X") + and stash it as a rich ``detail_json`` blob on the wishlist row, so the UI renders a full + card later without re-fetching. + +Like the other video handlers this lives on the SHARED automation side (so it may import +``core.video`` / ``api.video`` — the isolation contract only forbids the reverse) and owns +its own progress (``_manages_own_progress``). All I/O is injected as seams, so the logic is +a pure function in tests (no DB, no TMDB); production lazily binds the real calls. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Callable, Dict, Iterable, List, Optional + +from core.automation.deps import AutomationDeps +from core.video.discovery_gaps import filmography_gaps + + +# ── pure credit classification ──────────────────────────────────────────────── +# An actor "playing themselves" in a documentary / talk show / award broadcast isn't a +# dramatic role — drop those so the wishlist stays films they actually acted in. +_SELF_MARKERS = ('self', 'himself', 'herself', 'themselves', 'archive footage', + 'archival footage') + + +def is_self_credit(role) -> bool: + """True for a 'plays themselves' / archive-footage credit (role text only).""" + r = str(role or '').strip().lower() + return bool(r) and any(m in r for m in _SELF_MARKERS) + + +def is_actor_movie_credit(c: Dict[str, Any]) -> bool: + return (c.get('kind') == 'movie' + and str(c.get('department') or '') == 'Acting' + and not is_self_credit(c.get('role'))) + + +def is_director_movie_credit(c: Dict[str, Any]) -> bool: + return c.get('kind') == 'movie' and str(c.get('role') or '').strip().lower() == 'director' + + +def is_relevant_movie_credit(c: Any) -> bool: + """A movie this person ACTED in (not as themselves) or DIRECTED.""" + return isinstance(c, dict) and (is_actor_movie_credit(c) or is_director_movie_credit(c)) + + +def is_released(date_str: Any, today: str) -> bool: + """True if the release date is on/before ``today``. No date → NOT yet released, so we + monitor it (a later scan promotes once a real, past date arrives).""" + d = str(date_str or '').strip() + return bool(d) and d[:10] <= today + + +def _int_set(values: Iterable) -> set: + out = set() + for x in values or []: + try: + out.add(int(x)) + except (TypeError, ValueError): + continue + return out + + +def select_person_movie_gaps(credits: List[Dict[str, Any]], owned_ids: Iterable, + ignored_ids: Iterable, *, today: str) -> List[Dict[str, Any]]: + """The pure core: a followed person's un-owned actor/director MOVIE credits. + + Keeps only relevant movie credits, drops owned + ignored + duplicates, ranks by + popularity (via ``filmography_gaps``), and tags each with the wishlist ``_status`` it + should get — ``'wanted'`` if released, else ``'monitored'``. No I/O.""" + relevant = [c for c in (credits or []) if is_relevant_movie_credit(c)] + ignored = _int_set(ignored_ids) + out: List[Dict[str, Any]] = [] + for g in filmography_gaps(owned_ids, relevant, kinds=("movie",)): + if int(g['tmdb_id']) in ignored: + continue + g = dict(g) + g['_status'] = 'wanted' if is_released(g.get('date'), today) else 'monitored' + out.append(g) + return out + + +def build_detail_blob(detail: Optional[Dict[str, Any]], credit: Dict[str, Any], + person: Dict[str, Any]) -> Dict[str, Any]: + """Trim TMDB full-detail down to a rich-but-lean card blob + provenance. + + Drops the heavy ``_extras`` (similar/recommendations/keywords/reviews/providers) and + keeps what a wishlist card / detail view wants. Degrades to the credit's own fields when + ``detail`` is missing or a library redirect.""" + via = { + 'person_tmdb_id': person.get('tmdb_id'), + 'person_name': person.get('title') or person.get('name'), + 'role': credit.get('role'), + 'as': 'director' if is_director_movie_credit(credit) else 'actor', + } + if not detail or detail.get('redirect'): + return { + 'title': credit.get('title'), 'year': credit.get('year'), + 'release_date': credit.get('date'), 'poster_url': credit.get('poster'), + 'added_via': via, + } + director = next((p.get('name') for p in (detail.get('crew') or []) + if str(p.get('job')) == 'Director'), None) + return { + 'title': detail.get('title'), + 'overview': detail.get('overview'), + 'tagline': detail.get('tagline'), + 'status': detail.get('status'), + 'rating': detail.get('rating'), + 'imdb_id': detail.get('imdb_id'), + 'poster_url': detail.get('poster_url') or credit.get('poster'), + 'backdrop_url': detail.get('backdrop_url'), + 'logo': detail.get('logo'), + 'genres': detail.get('genres') or [], + 'runtime_minutes': detail.get('runtime_minutes'), + 'studio': detail.get('studio'), + 'year': detail.get('year') or credit.get('year'), + 'release_date': detail.get('release_date') or credit.get('date'), + 'cast': (detail.get('cast') or [])[:15], + 'director': director, + 'added_via': via, + } + + +# ── production seams ────────────────────────────────────────────────────────── +def _default_fetch_people() -> List[Dict[str, Any]]: + from api.video import get_video_db + return get_video_db().list_watchlist('person') + + +def _default_fetch_credits(tmdb_id: Any) -> List[Dict[str, Any]]: + from core.video.enrichment.engine import get_video_enrichment_engine + d = get_video_enrichment_engine().person_detail(tmdb_id) or {} + return d.get('credits') or [] + + +def _default_fetch_detail(tmdb_id: Any) -> Optional[Dict[str, Any]]: + from core.video.enrichment.engine import get_video_enrichment_engine + return get_video_enrichment_engine().tmdb_detail('movie', tmdb_id) + + +def _default_owned_ids() -> Iterable: + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().owned_movie_tmdb_ids(resolve_video_server()) + + +def _default_ignored_ids() -> List[Any]: + from api.video import get_video_db + return [r.get('tmdb_id') for r in (get_video_db().list_ignored() or []) + if r.get('kind') == 'movie'] + + +def _default_wishlisted_status() -> Dict[int, str]: + from api.video import get_video_db + return get_video_db().wishlisted_movie_status() + + +def _default_add_movie(tmdb_id, title, *, year, poster_url, status, detail_json) -> bool: + from api.video import get_video_db + from core.video.sources import resolve_video_server + return get_video_db().add_movie_to_wishlist( + tmdb_id, title, year=year, poster_url=poster_url, status=status, + detail_json=detail_json, server_source=resolve_video_server()) + + +def auto_video_scan_watchlist_people( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_people: Optional[Callable[[], List[Dict[str, Any]]]] = None, + fetch_credits: Optional[Callable[[Any], List[Dict[str, Any]]]] = None, + fetch_detail: Optional[Callable[[Any], Optional[Dict[str, Any]]]] = None, + owned_ids: Optional[Callable[[], Iterable]] = None, + ignored_ids: Optional[Callable[[], List[Any]]] = None, + wishlisted_status: Optional[Callable[[], Dict[int, str]]] = None, + add_movie: Optional[Callable[..., bool]] = None, + today_fn: Optional[Callable[[], str]] = None, +) -> Dict[str, Any]: + """Scan every followed person's filmography and wishlist the movies the user is missing. + + Returns ``{'status': 'completed', 'people': int, 'movies_added': int, 'upcoming': int, + 'promoted': int, ...}`` — ``movies_added`` = released wishlisted, ``upcoming`` = monitored + (unreleased) wishlisted, ``promoted`` = monitored rows flipped to wanted now they're out.""" + fetch_people = fetch_people or _default_fetch_people + fetch_credits = fetch_credits or _default_fetch_credits + fetch_detail = fetch_detail or _default_fetch_detail + owned_ids = owned_ids or _default_owned_ids + ignored_ids = ignored_ids or _default_ignored_ids + wishlisted_status = wishlisted_status or _default_wishlisted_status + add_movie = add_movie or _default_add_movie + today_fn = today_fn or (lambda: date.today().isoformat()) + automation_id = config.get('_automation_id') + + try: + today = today_fn() + deps.update_progress(automation_id, phase='Reading your watchlist…', progress=5, + log_line='Loading the people you follow', log_type='info') + people = fetch_people() or [] + if not people: + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line='No people on the watchlist to scan', log_type='info') + return {'status': 'completed', 'people': 0, 'movies_added': 0, 'upcoming': 0, + 'promoted': 0, '_manages_own_progress': True} + + owned = owned_ids() or set() + ignored = ignored_ids() or [] + # Snapshot of what's already wishlisted {tmdb_id: status}; updated as we add so a + # movie credited to two followed people is handled once. + wished: Dict[int, str] = dict(wishlisted_status() or {}) + + added = upcoming = promoted = 0 + total = len(people) + for i, person in enumerate(people): + pid = person.get('tmdb_id') + pname = person.get('title') or person.get('name') or pid + deps.update_progress(automation_id, phase='Scanning filmographies…', + progress=10 + int(80 * i / max(total, 1)), + log_line="Looking up %s's movies" % pname, log_type='info') + if not pid: + continue + try: + credits = fetch_credits(pid) or [] + except Exception: # noqa: BLE001 - one bad lookup shouldn't abort the whole scan + deps.update_progress(automation_id, log_line="Couldn't fetch %s — skipping" % pname, + log_type='warning') + continue + + for g in select_person_movie_gaps(credits, owned, ignored, today=today): + tid = int(g['tmdb_id']) + want = g['_status'] # 'wanted' | 'monitored' + existing = wished.get(tid) + if existing is not None: + # Already wishlisted — the only action left is promoting a monitored + # row that has since been released (don't re-fetch detail for it). + if existing == 'monitored' and want == 'wanted': + if add_movie(tid, g.get('title'), year=g.get('year'), + poster_url=g.get('poster'), status='wanted', detail_json=None): + wished[tid] = 'wanted' + promoted += 1 + deps.update_progress( + automation_id, log_type='success', + log_line="'%s' is out now — promoted to wanted" + % (g.get('title') or tid)) + continue + + # New gap — grab the rich detail (cached), build the blob, add it. + try: + detail = fetch_detail(tid) + except Exception: # noqa: BLE001 - degrade to the cheap credit fields + detail = None + blob = build_detail_blob(detail, g, person) + if add_movie(tid, g.get('title') or blob.get('title'), year=g.get('year'), + poster_url=blob.get('poster_url') or g.get('poster'), + status=want, detail_json=blob): + wished[tid] = want + if want == 'wanted': + added += 1 + else: + upcoming += 1 + + parts = [] + if added: + parts.append('%d new movie(s)' % added) + if upcoming: + parts.append('%d upcoming' % upcoming) + if promoted: + parts.append('%d promoted' % promoted) + done = ('Wishlisted ' + ', '.join(parts) + ' across %d followed person(s)' % total) \ + if parts else ('Watchlist is up to date — nothing new across %d person(s)' % total) + deps.update_progress(automation_id, status='finished', progress=100, phase='Complete', + log_line=done, log_type='success') + return {'status': 'completed', 'people': total, 'movies_added': added, + 'upcoming': upcoming, 'promoted': promoted, '_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} diff --git a/database/video_database.py b/database/video_database.py index e38c9c66..725bdf2a 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -162,6 +162,10 @@ _COLUMN_MIGRATIONS = [ ("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time) ("video_wishlist", "season_poster_url", "TEXT"), # the episode's season poster ("video_wishlist", "episode_overview", "TEXT"), # episode synopsis + # rich movie detail captured at add time (backdrop, overview, genres, runtime, rating, + # top cast, director, release_date, + provenance) so the wishlist renders a full card + # without re-fetching. JSON blob; NULL on rows added before this / by lean paths. + ("video_wishlist", "detail_json", "TEXT"), # generic source bridge (YouTube channels/videos ride the existing tables) ("video_watchlist", "source", "TEXT NOT NULL DEFAULT 'tmdb'"), ("video_watchlist", "source_id", "TEXT"), @@ -2375,21 +2379,39 @@ class VideoDatabase: # into episode rows (the caller supplies the explicit episodes); show/season # are just bulk add/remove operations over those rows. def add_movie_to_wishlist(self, tmdb_id, title, *, year=None, poster_url=None, - library_id=None, server_source=None) -> bool: - """Wish for a movie. Idempotent upsert on its tmdb id.""" + library_id=None, server_source=None, status='wanted', + detail_json=None) -> bool: + """Wish for a movie. Idempotent upsert on its tmdb id. + + ``status`` lets the watchlist-people scan add an UPCOMING (unreleased) movie as + 'monitored' so the wishlist engine skips it until it's out; a later scan re-adds it + with 'wanted' once released, which PROMOTES it. We never downgrade a status the engine + has already advanced (searching/downloading/downloaded/failed) and never knock a + 'wanted' back to 'monitored'. + + ``detail_json`` is a rich TMDB-detail blob (backdrop/overview/genres/cast/etc., + captured at add time) so the wishlist renders a full card without re-fetching; it's + only filled in, never wiped, on re-add. + """ if not tmdb_id or not title: return False + if isinstance(detail_json, (dict, list)): + import json as _json + detail_json = _json.dumps(detail_json) conn = self._get_connection() try: conn.execute( - """INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, year, library_id, server_source) - VALUES ('movie', ?, ?, ?, ?, ?, ?) + """INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, year, library_id, server_source, status, detail_json) + VALUES ('movie', ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tmdb_id) WHERE kind='movie' DO UPDATE SET title=excluded.title, poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url), year=COALESCE(excluded.year, video_wishlist.year), - library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""", - (int(tmdb_id), title, poster_url, year, library_id, server_source)) + library_id=COALESCE(excluded.library_id, video_wishlist.library_id), + detail_json=COALESCE(excluded.detail_json, video_wishlist.detail_json), + status=CASE WHEN video_wishlist.status='monitored' AND excluded.status='wanted' + THEN 'wanted' ELSE video_wishlist.status END""", + (int(tmdb_id), title, poster_url, year, library_id, server_source, status, detail_json)) conn.commit() return True except Exception: @@ -2398,6 +2420,20 @@ class VideoDatabase: finally: conn.close() + def wishlisted_movie_status(self) -> dict: + """{tmdb_id: status} for every movie on the wishlist. Lets the watchlist-people + scan skip movies it's already handled (fast re-runs) and spot 'monitored' rows + that are now releasable so it can promote them.""" + conn = self._get_connection() + try: + return {int(r["tmdb_id"]): r["status"] for r in conn.execute( + "SELECT tmdb_id, status FROM video_wishlist WHERE kind='movie' AND tmdb_id IS NOT NULL")} + except Exception: + logger.exception("wishlisted_movie_status failed") + return {} + 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 89b7c722..5a4873be 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -60,6 +60,8 @@ EXPECTED_ACTION_NAMES = frozenset({ 'video_scan_server', 'video_update_database', 'video_add_airing_episodes', + 'video_scan_watchlist_people', + 'video_scan_watchlist_channels', 'video_clean_search_history', 'video_clean_completed_downloads', 'video_full_cleanup', diff --git a/tests/test_video_scan_watchlist_channels.py b/tests/test_video_scan_watchlist_channels.py new file mode 100644 index 00000000..813379f3 --- /dev/null +++ b/tests/test_video_scan_watchlist_channels.py @@ -0,0 +1,197 @@ +"""Watchlist-channels scan automation: for every followed YouTube channel, wishlist its new +long-form uploads — forward-looking from follow time + a last-N safety net, never duplicating. + +Pure selection logic with all I/O injected (no DB, no yt-dlp). +""" + +from __future__ import annotations + +from core.automation.handlers.video_scan_watchlist_channels import ( + auto_video_scan_watchlist_channels, + is_short, + long_form_uploads, + select_channel_video_gaps, +) + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _vid(vid, *, date=None, dur=600, title=None): + return {"youtube_id": vid, "title": title or ("Video " + vid), "published_at": date, + "duration_seconds": dur, "thumbnail_url": "/t/" + vid + ".jpg"} + + +# ── pure: shorts filtering ──────────────────────────────────────────────────── +def test_is_short_only_on_known_short_duration(): + assert is_short(_vid("a", dur=45), 60) + assert not is_short(_vid("b", dur=600), 60) + assert not is_short(_vid("c", dur=None), 60) # unknown ≠ short (Videos tab already filters) + assert not is_short(_vid("d", dur=0), 60) + + +def test_long_form_drops_shorts_and_idless(): + ups = [_vid("a", dur=30), _vid("b", dur=600), {"title": "no id"}, _vid("c", dur=120)] + assert [v["youtube_id"] for v in long_form_uploads(ups, 60)] == ["b", "c"] + + +# ── pure: gap selection ─────────────────────────────────────────────────────── +def test_net_backfills_last_n_even_before_baseline(): + # newest-first; baseline is "today" (just followed) so nothing is after baseline, + # but the last-2 net still backlogs the two most recent. + ups = [_vid("v1", date="2026-06-20"), _vid("v2", date="2026-06-10"), + _vid("v3", date="2026-05-01"), _vid("v4", date="2026-01-01")] + gaps = select_channel_video_gaps(ups, baseline_date="2026-06-25", backfill_count=2, + wishlisted_ids=[], today="2026-06-25") + assert [g["youtube_id"] for g in gaps] == ["v1", "v2"] + + +def test_forward_looking_grabs_everything_after_baseline_beyond_net(): + # 4 uploads all AFTER the follow date → all wishlisted even though net is only 1 + ups = [_vid("v1", date="2026-06-24"), _vid("v2", date="2026-06-22"), + _vid("v3", date="2026-06-20"), _vid("v4", date="2026-06-18")] + gaps = select_channel_video_gaps(ups, baseline_date="2026-06-15", backfill_count=1, + wishlisted_ids=[], today="2026-06-25") + assert [g["youtube_id"] for g in gaps] == ["v1", "v2", "v3", "v4"] + + +def test_old_videos_before_baseline_and_outside_net_are_ignored(): + # "what they had before isn't our concern" — v3/v4 predate the follow + aren't in the net + ups = [_vid("v1", date="2026-06-24"), _vid("v2", date="2026-06-23"), + _vid("v3", date="2020-01-01"), _vid("v4", date="2019-01-01")] + gaps = select_channel_video_gaps(ups, baseline_date="2026-06-20", backfill_count=2, + wishlisted_ids=[], today="2026-06-25") + assert [g["youtube_id"] for g in gaps] == ["v1", "v2"] + + +def test_excludes_already_wishlisted_downloaded_dismissed(): + ups = [_vid("v1", date="2026-06-24"), _vid("v2", date="2026-06-23"), + _vid("v3", date="2026-06-22")] + gaps = select_channel_video_gaps( + ups, baseline_date="2026-06-01", backfill_count=3, + wishlisted_ids=["v1"], downloaded_ids=["v2"], dismissed_ids=["v3"], today="2026-06-25") + assert gaps == [] # all three already accounted for + + +def test_skips_future_dated_premieres(): + ups = [_vid("soon", date="2099-01-01"), _vid("out", date="2026-06-20")] + gaps = select_channel_video_gaps(ups, baseline_date="2026-06-01", backfill_count=5, + wishlisted_ids=[], today="2026-06-25") + assert [g["youtube_id"] for g in gaps] == ["out"] # the unaired premiere is left alone + + +def test_shorts_never_counted_in_net_or_added(): + ups = [_vid("short1", dur=20, date="2026-06-24"), _vid("real1", dur=600, date="2026-06-23"), + _vid("real2", dur=600, date="2026-06-22")] + gaps = select_channel_video_gaps(ups, baseline_date="2026-06-25", backfill_count=1, + wishlisted_ids=[], today="2026-06-25") + assert [g["youtube_id"] for g in gaps] == ["real1"] # net=1 long-form, the Short is invisible + + +# ── handler: end to end with seams ──────────────────────────────────────────── +def _handler(channels, uploads_by_channel, *, wished=None, downloaded=None, dismissed=None, + today="2026-06-25", config=None): + adds = [] + + def add_videos(channel, videos): + adds.append((channel["youtube_id"], [v["youtube_id"] for v in videos])) + return len(videos) + + deps = _Deps() + res = auto_video_scan_watchlist_channels( + {"_automation_id": "a1", **(config or {})}, deps, + fetch_channels=lambda: channels, + fetch_uploads=lambda cid, limit: uploads_by_channel.get(cid, []), + wishlisted_ids=lambda cid: (wished or {}).get(cid, []), + downloaded_ids=lambda cid: (downloaded or {}).get(cid, []), + dismissed_ids=lambda cid: (dismissed or {}).get(cid, []), + add_videos=add_videos, today_fn=lambda: today) + return res, adds, deps + + +def test_first_run_backlogs_last_n_then_steady_state_is_incremental(): + channels = [{"youtube_id": "UC1", "title": "Cool Channel", + "poster_url": "/avatar.jpg", "date_added": "2026-06-25 09:00:00"}] + ups = [_vid("v%d" % i, date="2026-06-%02d" % (25 - i)) for i in range(15)] + res, adds, _ = _handler(channels, {"UC1": ups}, config={"backfill_count": 10}) + assert res["status"] == "completed" and res["channels"] == 1 + assert res["videos_added"] == 10 # net backfill on the first run + assert adds[0][0] == "UC1" and len(adds[0][1]) == 10 + # channel passed to add carries id + title + avatar so the wishlist orb renders + # (verified via the add tuple shape above; avatar travels in the channel dict) + + +def test_rerun_only_adds_genuinely_new_videos(): + channels = [{"youtube_id": "UC1", "title": "Ch", "date_added": "2026-06-01 00:00:00"}] + ups = [_vid("new", date="2026-06-25"), _vid("old1", date="2026-06-10"), + _vid("old2", date="2026-06-09")] + # old1/old2 already wishlisted from a prior scan → only 'new' is added + res, adds, _ = _handler(channels, {"UC1": ups}, wished={"UC1": ["old1", "old2"]}, + config={"backfill_count": 3}) + assert adds == [("UC1", ["new"])] and res["videos_added"] == 1 + + +def test_nothing_new_adds_nothing(): + channels = [{"youtube_id": "UC1", "title": "Ch", "date_added": "2026-06-01"}] + ups = [_vid("a", date="2026-06-20"), _vid("b", date="2026-06-19")] + res, adds, _ = _handler(channels, {"UC1": ups}, wished={"UC1": ["a", "b"]}, + config={"backfill_count": 2}) + assert adds == [] and res["videos_added"] == 0 + + +def test_multiple_channels_scanned_independently(): + channels = [ + {"youtube_id": "UC1", "title": "A", "date_added": "2026-06-25"}, + {"youtube_id": "UC2", "title": "B", "date_added": "2026-06-25"}, + ] + uploads = {"UC1": [_vid("a1", date="2026-06-24")], "UC2": [_vid("b1", date="2026-06-24")]} + res, adds, _ = _handler(channels, uploads, config={"backfill_count": 5}) + assert res["channels"] == 2 and res["videos_added"] == 2 + assert sorted(c for c, _ in adds) == ["UC1", "UC2"] + + +def test_one_unreachable_channel_does_not_abort_the_scan(): + channels = [{"youtube_id": "UC1", "title": "Breaks", "date_added": "2026-06-25"}, + {"youtube_id": "UC2", "title": "Works", "date_added": "2026-06-25"}] + + def fetch_uploads(cid, limit): + if cid == "UC1": + raise RuntimeError("yt-dlp blew up") + return [_vid("ok", date="2026-06-24")] + + adds = [] + res = auto_video_scan_watchlist_channels( + {"_automation_id": "a", "backfill_count": 5}, _Deps(), + fetch_channels=lambda: channels, fetch_uploads=fetch_uploads, + wishlisted_ids=lambda cid: [], downloaded_ids=lambda cid: [], + dismissed_ids=lambda cid: [], add_videos=lambda ch, v: len(v), + today_fn=lambda: "2026-06-25") + assert res["status"] == "completed" and res["videos_added"] == 1 + + +def test_empty_watchlist_is_a_clean_noop(): + res, adds, _ = _handler([], {}) + assert res["status"] == "completed" and res["channels"] == 0 and adds == [] + + +def test_missing_follow_date_falls_back_to_net_only(): + # no date_added → baseline=today, so only the net backfills (no spurious old grabs) + channels = [{"youtube_id": "UC1", "title": "Ch"}] + ups = [_vid("v1", date="2026-06-20"), _vid("v2", date="2026-06-19"), + _vid("v3", date="2020-01-01")] + res, adds, _ = _handler(channels, {"UC1": ups}, config={"backfill_count": 2}) + assert adds == [("UC1", ["v1", "v2"])] + + +def test_top_level_error_is_caught_and_reported(): + def boom(): + raise RuntimeError("watchlist read failed") + deps = _Deps() + res = auto_video_scan_watchlist_channels({"_automation_id": "a"}, deps, fetch_channels=boom) + assert res["status"] == "error" and "watchlist read failed" in res["error"] + assert any(p.get("status") == "error" for p in deps.progress) diff --git a/tests/test_video_scan_watchlist_people.py b/tests/test_video_scan_watchlist_people.py new file mode 100644 index 00000000..a2a5c3a5 --- /dev/null +++ b/tests/test_video_scan_watchlist_people.py @@ -0,0 +1,346 @@ +"""Watchlist-people scan automation: for every followed person, wishlist every un-owned +MOVIE they acted in or directed (back catalog + upcoming). + +Pure logic with all I/O injected (no DB, no TMDB), plus DB-seam tests for the new +``add_movie_to_wishlist`` status/detail_json behaviour + ``wishlisted_movie_status``. +""" + +from __future__ import annotations + +import json + +import pytest + +from core.automation.handlers.video_scan_watchlist_people import ( + auto_video_scan_watchlist_people, + build_detail_blob, + is_director_movie_credit, + is_released, + is_relevant_movie_credit, + is_self_credit, + select_person_movie_gaps, +) + + +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _credit(tid, title, *, dept="Acting", role="A Character", date="2000-01-01", + pop=10.0, kind="movie", poster="/p.jpg"): + return {"kind": kind, "tmdb_id": tid, "title": title, "department": dept, "role": role, + "date": date, "year": (date or "")[:4] or None, "popularity": pop, "poster": poster} + + +# ── pure credit classification ──────────────────────────────────────────────── +def test_is_self_credit(): + assert is_self_credit("Self") + assert is_self_credit("Himself") + assert is_self_credit("Self - Host") + assert is_self_credit("Narrator (archive footage)") + assert not is_self_credit("Bruce Wayne") + assert not is_self_credit("") + assert not is_self_credit(None) + + +def test_relevant_movie_credit(): + assert is_relevant_movie_credit(_credit(1, "Acted", dept="Acting", role="Hero")) + # crew director (TMDB files directors under the Directing department, job=Director) + assert is_relevant_movie_credit(_credit(2, "Directed", dept="Directing", role="Director")) + assert is_director_movie_credit(_credit(2, "Directed", dept="Directing", role="Director")) + # 'plays themselves' is dropped + assert not is_relevant_movie_credit(_credit(3, "Doc", dept="Acting", role="Self")) + # a TV credit is never relevant (movies only) + assert not is_relevant_movie_credit(_credit(4, "TV", kind="show", role="Host")) + # other crew (writer/producer) doesn't count + assert not is_relevant_movie_credit(_credit(5, "Wrote", dept="Writing", role="Writer")) + + +def test_is_released(): + assert is_released("1999-05-01", "2026-06-25") + assert is_released("2026-06-25", "2026-06-25") # today counts as released + assert not is_released("2027-01-01", "2026-06-25") # future + assert not is_released("", "2026-06-25") # no date → not yet released + assert not is_released(None, "2026-06-25") + + +def test_select_filters_owned_ignored_and_tags_status(): + credits = [ + _credit(1, "Owned", pop=99), # owned → dropped + _credit(2, "Ignored", pop=98), # ignored → dropped + _credit(3, "Old Hit", pop=50, date="1990-01-01"), # released actor → wanted + _credit(4, "Future Film", pop=40, date="2099-01-01", # upcoming director → monitored + dept="Directing", role="Director"), + _credit(5, "TV Thing", kind="show", pop=80), # show → dropped + _credit(6, "Doc Self", role="Self", pop=70), # self → dropped + _credit(7, "Wrote It", dept="Writing", role="Writer", pop=60), # writer → dropped + ] + gaps = select_person_movie_gaps(credits, owned_ids={1}, ignored_ids={2}, today="2026-06-25") + ids = [(g["tmdb_id"], g["_status"]) for g in gaps] + assert ids == [(3, "wanted"), (4, "monitored")] # only relevant, popularity-ranked + + +def test_select_ranks_by_popularity(): + credits = [_credit(10, "Low", pop=1), _credit(11, "High", pop=99), _credit(12, "Mid", pop=50)] + gaps = select_person_movie_gaps(credits, owned_ids=set(), ignored_ids=set(), today="2026-06-25") + assert [g["tmdb_id"] for g in gaps] == [11, 12, 10] + + +# ── rich detail blob ────────────────────────────────────────────────────────── +def _full_detail(): + return { + "kind": "movie", "tmdb_id": 3, "title": "Old Hit", "overview": "A synopsis.", + "tagline": "Tag.", "status": "Released", "rating": 7.8, "imdb_id": "tt1", + "poster_url": "https://img/p.jpg", "backdrop_url": "https://img/b.jpg", + "logo": "https://img/l.png", "genres": ["Drama", "Thriller"], + "runtime_minutes": 121, "studio": "A24", "year": "1990", "release_date": "1990-01-01", + "cast": [{"name": "P%d" % i, "character": "C%d" % i} for i in range(20)], + "crew": [{"name": "The Director", "job": "Director"}, {"name": "W", "job": "Writer"}], + "_extras": {"similar": ["lots", "of", "heavy", "data"]}, + } + + +def test_build_detail_blob_trims_and_adds_provenance(): + credit = _credit(3, "Old Hit", role="Lead Role") + person = {"tmdb_id": 500, "title": "Famous Actor"} + blob = build_detail_blob(_full_detail(), credit, person) + assert blob["overview"] == "A synopsis." + assert blob["backdrop_url"] == "https://img/b.jpg" + assert blob["genres"] == ["Drama", "Thriller"] + assert blob["director"] == "The Director" + assert len(blob["cast"]) == 15 # capped, not the full 20 + assert "_extras" not in blob # heavy data dropped + via = blob["added_via"] + assert via == {"person_tmdb_id": 500, "person_name": "Famous Actor", + "role": "Lead Role", "as": "actor"} + + +def test_build_detail_blob_marks_director_provenance(): + credit = _credit(4, "Directed", dept="Directing", role="Director") + blob = build_detail_blob(_full_detail(), credit, {"tmdb_id": 1, "title": "Auteur"}) + assert blob["added_via"]["as"] == "director" + + +def test_build_detail_blob_degrades_without_detail(): + credit = _credit(9, "Lost Detail", role="X", poster="/credit-poster.jpg") + for detail in (None, {"redirect": {"source": "library", "id": 7}}): + blob = build_detail_blob(detail, credit, {"tmdb_id": 1, "title": "P"}) + assert blob["poster_url"] == "/credit-poster.jpg" # falls back to the credit + assert blob["title"] == "Lost Detail" + assert blob["added_via"]["person_name"] == "P" + + +# ── handler: first-run backlog ──────────────────────────────────────────────── +def _handler(people, credits_by_person, *, owned=None, ignored=None, wished=None, + detail=None, today="2026-06-25"): + """Run the handler with fakes; return (result, list-of-add-calls, deps).""" + adds = [] + detail_calls = [] + + def add_movie(tmdb_id, title, *, year, poster_url, status, detail_json): + adds.append({"tmdb_id": tmdb_id, "title": title, "year": year, "poster_url": poster_url, + "status": status, "detail_json": detail_json}) + return True + + def fetch_detail(tid): + detail_calls.append(tid) + return (detail or {}).get(tid) + + deps = _Deps() + res = auto_video_scan_watchlist_people( + {"_automation_id": "a1"}, deps, + fetch_people=lambda: people, + fetch_credits=lambda pid: credits_by_person.get(pid, []), + fetch_detail=fetch_detail, + owned_ids=lambda: set(owned or set()), + ignored_ids=lambda: list(ignored or []), + wishlisted_status=lambda: dict(wished or {}), + add_movie=add_movie, + today_fn=lambda: today) + return res, adds, detail_calls, deps + + +def test_first_run_backlogs_released_and_upcoming(): + people = [{"tmdb_id": 500, "title": "Famous Actor"}] + credits = {500: [ + _credit(3, "Old Hit", pop=50, date="1990-01-01"), # released → wanted + _credit(4, "Future Film", pop=40, date="2099-01-01"), # upcoming → monitored + _credit(1, "Owned Movie", pop=80, date="2000-01-01"), # owned → skipped + _credit(7, "A Show", kind="show", pop=90), # show → skipped + ]} + detail = {3: _full_detail(), 4: {"kind": "movie", "title": "Future Film", + "poster_url": "https://img/f.jpg", "cast": [], "crew": []}} + res, adds, detail_calls, _ = _handler(people, credits, owned={1}, detail=detail) + + assert res["status"] == "completed" + assert res["people"] == 1 + assert res["movies_added"] == 1 and res["upcoming"] == 1 + by_id = {a["tmdb_id"]: a for a in adds} + assert set(by_id) == {3, 4} + # released one is 'wanted' and carries the RICH blob (best-in-class data at add time) + assert by_id[3]["status"] == "wanted" + assert by_id[3]["detail_json"]["backdrop_url"] == "https://img/b.jpg" + assert by_id[3]["detail_json"]["added_via"]["person_name"] == "Famous Actor" + # upcoming one is 'monitored' so the (future) wishlist engine leaves it alone + assert by_id[4]["status"] == "monitored" + assert sorted(detail_calls) == [3, 4] # detail fetched for each new gap + + +def test_owned_movies_are_never_wishlisted(): + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "Have It", date="1990-01-01")]} + res, adds, _, _ = _handler(people, credits, owned={10}) + assert adds == [] and res["movies_added"] == 0 + + +def test_ignored_movies_are_skipped(): + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "Not Interested", date="1990-01-01")]} + res, adds, _, _ = _handler(people, credits, ignored=[10]) + assert adds == [] and res["movies_added"] == 0 + + +# ── handler: fast re-runs (skip / promote) ──────────────────────────────────── +def test_rerun_skips_already_wishlisted_without_refetch(): + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "Already Wished", date="1990-01-01")]} + res, adds, detail_calls, _ = _handler(people, credits, wished={10: "wanted"}) + assert adds == [] # no re-add + assert detail_calls == [] # and no wasted detail fetch + assert res["movies_added"] == 0 + + +def test_rerun_promotes_monitored_now_that_it_released(): + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "Finally Out", date="2020-01-01")]} # now in the past + res, adds, detail_calls, _ = _handler(people, credits, wished={10: "monitored"}) + assert len(adds) == 1 + assert adds[0]["status"] == "wanted" # promoted + assert adds[0]["detail_json"] is None # don't clobber the stored rich blob + assert detail_calls == [] # promotion needs no detail fetch + assert res["promoted"] == 1 and res["movies_added"] == 0 + + +def test_rerun_leaves_still_upcoming_monitored_alone(): + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "Still Coming", date="2099-01-01")]} + res, adds, _, _ = _handler(people, credits, wished={10: "monitored"}) + assert adds == [] and res["promoted"] == 0 + + +def test_engine_advanced_status_is_never_downgraded(): + # a movie already 'downloading' must not be touched by the scan + people = [{"tmdb_id": 1, "title": "P"}] + credits = {1: [_credit(10, "In Flight", date="1990-01-01")]} + res, adds, _, _ = _handler(people, credits, wished={10: "downloading"}) + assert adds == [] + + +# ── handler: cross-person dedup + resilience ────────────────────────────────── +def test_movie_shared_by_two_people_is_added_once(): + people = [{"tmdb_id": 1, "title": "Actor A"}, {"tmdb_id": 2, "title": "Actor B"}] + shared = _credit(10, "Co-Stars", date="1990-01-01") + credits = {1: [shared], 2: [dict(shared)]} + res, adds, detail_calls, _ = _handler(people, credits, detail={10: _full_detail()}) + assert len(adds) == 1 # second person sees it already handled + assert detail_calls == [10] and res["movies_added"] == 1 + + +def test_one_persons_fetch_error_does_not_abort_the_scan(): + people = [{"tmdb_id": 1, "title": "Breaks"}, {"tmdb_id": 2, "title": "Works"}] + credits = {2: [_credit(10, "Good", date="1990-01-01")]} + + def fetch_credits(pid): + if pid == 1: + raise RuntimeError("tmdb down") + return credits.get(pid, []) + + adds = [] + res = auto_video_scan_watchlist_people( + {"_automation_id": "a"}, _Deps(), + fetch_people=lambda: people, fetch_credits=fetch_credits, + fetch_detail=lambda t: _full_detail(), + owned_ids=lambda: set(), ignored_ids=lambda: [], wishlisted_status=lambda: {}, + add_movie=lambda *a, **k: adds.append(k) or True, today_fn=lambda: "2026-06-25") + assert res["status"] == "completed" + assert res["movies_added"] == 1 # person 2 still processed + + +def test_empty_watchlist_is_a_clean_noop(): + res, adds, _, _ = _handler([], {}) + assert res["status"] == "completed" and res["people"] == 0 and adds == [] + + +def test_top_level_error_is_caught_and_reported(): + def boom(): + raise RuntimeError("watchlist read failed") + deps = _Deps() + res = auto_video_scan_watchlist_people({"_automation_id": "a"}, deps, fetch_people=boom) + assert res["status"] == "error" and "watchlist read failed" in res["error"] + assert any(p.get("status") == "error" for p in deps.progress) + + +# ── DB seam: status + detail_json upsert semantics ──────────────────────────── +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 _row(db, tmdb_id): + with db._get_connection() as conn: + r = conn.execute("SELECT status, detail_json FROM video_wishlist " + "WHERE tmdb_id=? AND kind='movie'", (tmdb_id,)).fetchone() + return (r["status"], r["detail_json"]) if r else (None, None) + + +def test_add_movie_stores_status_and_detail_json(db): + blob = {"overview": "x", "added_via": {"person_name": "P"}} + assert db.add_movie_to_wishlist(10, "M", year="1990", status="wanted", detail_json=blob) + status, dj = _row(db, 10) + assert status == "wanted" + assert json.loads(dj)["added_via"]["person_name"] == "P" # dict was serialized + assert db.wishlisted_movie_status() == {10: "wanted"} + + +def test_default_status_is_wanted_for_back_compat(db): + # existing callers that don't pass status keep getting 'wanted' + db.add_movie_to_wishlist(11, "Legacy") + assert _row(db, 11)[0] == "wanted" + + +def test_monitored_is_promoted_to_wanted_but_never_downgraded(db): + db.add_movie_to_wishlist(12, "Upcoming", status="monitored") + assert _row(db, 12)[0] == "monitored" + # re-add as wanted (it released) → promoted + db.add_movie_to_wishlist(12, "Upcoming", status="wanted") + assert _row(db, 12)[0] == "wanted" + # re-add as monitored again must NOT knock it back + db.add_movie_to_wishlist(12, "Upcoming", status="monitored") + assert _row(db, 12)[0] == "wanted" + + +def test_engine_status_survives_a_rescan(db): + db.add_movie_to_wishlist(13, "Grabbing", status="wanted") + with db._get_connection() as conn: + conn.execute("UPDATE video_wishlist SET status='downloading' WHERE tmdb_id=13") + conn.commit() + # a later scan re-adds it as 'wanted' — must not undo the engine's progress + db.add_movie_to_wishlist(13, "Grabbing", status="wanted") + assert _row(db, 13)[0] == "downloading" + + +def test_detail_json_is_filled_not_wiped_on_readd(db): + blob = {"overview": "rich"} + db.add_movie_to_wishlist(14, "M", status="monitored", detail_json=blob) + # promotion re-add passes no detail_json — the stored blob must remain + db.add_movie_to_wishlist(14, "M", status="wanted", detail_json=None) + status, dj = _row(db, 14) + assert status == "wanted" + assert json.loads(dj)["overview"] == "rich" diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 06f5bddd..8d5733ef 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1776,6 +1776,7 @@ const _autoIcons = { // Video side video_scan_library: '\uD83C\uDFAC', video_scan_server: '\uD83D\uDD04', video_update_database: '\uD83D\uDDC4\uFE0F', 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', }; // --- Inspiration Templates --- @@ -3370,6 +3371,8 @@ function _autoFormatAction(type) { video_scan_library: 'Scan Video Library', video_scan_server: 'Scan Video Server', video_update_database: 'Update Video Database', video_add_airing_episodes: 'Wishlist Airing Episodes', video_deep_scan_movies: 'Deep Scan Movie Library', video_deep_scan_tv: 'Deep Scan TV Library', + video_scan_watchlist_people: 'Scan Watchlist People', + video_scan_watchlist_channels: 'Scan Watchlist Channels', }; return labels[type] || type || 'Unknown'; }