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:
parent
877a86c9b0
commit
31d284ab0d
9 changed files with 1141 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
234
core/automation/handlers/video_scan_watchlist_channels.py
Normal file
234
core/automation/handlers/video_scan_watchlist_channels.py
Normal 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}
|
||||
296
core/automation/handlers/video_scan_watchlist_people.py
Normal file
296
core/automation/handlers/video_scan_watchlist_people.py
Normal 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}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
197
tests/test_video_scan_watchlist_channels.py
Normal file
197
tests/test_video_scan_watchlist_channels.py
Normal 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)
|
||||
346
tests/test_video_scan_watchlist_people.py
Normal file
346
tests/test_video_scan_watchlist_people.py
Normal 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"
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue