soulsync/tests/test_video_scan_watchlist_people.py
BoulderBadgeDad 31d284ab0d 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.
2026-06-25 19:07:06 -07:00

346 lines
16 KiB
Python

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