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.
This commit is contained in:
BoulderBadgeDad 2026-06-25 19:07:06 -07:00
parent 877a86c9b0
commit 31d284ab0d
9 changed files with 1141 additions and 6 deletions

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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}

View file

@ -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).

View file

@ -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',

View file

@ -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)

View file

@ -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"

View file

@ -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';
}