Full-epic /deep-review (11 finders + 12 adversarial verifiers) surfaced no HIGH; every mechanism finding calibrated to LOW on verification. Fix all 13: Correctness / reliability: - Combobox: Enter now commits the VISUALLY highlighted row (highlightIndex ?? activeIndex), not the bare keyboard activeIndex — so arrow-then-hover-then-Enter no longer toggles a different option than the one painted, and hover-with-no-arrow is no longer a silent no-op. - parse_person: dedup the known_for pool by (id, media_type), not the bare id — TMDB movie and tv id spaces are independent, so a same-id movie+show pair is two distinct credits (matches the collision-safe key the rest of the module already uses). - person-enrich: a per-person in-flight + 60s cooldown guard (people._should_schedule_enrich) dedups concurrent opens and rate-limits re-triggering while TMDB is degraded (a transient TmdbError leaves the watermark NULL), so bursts can't pile 8s tasks onto the shared threadpool. - PlexInfo compact()/money(): format with i18n.language (keeping currency USD) instead of a hardcoded en/en-US, matching the app's Intl convention. - delete the dead `plex.filter.add` i18n key (both locales) — the +Add button that used it is gone. Reuse: - _continue_watching reuses rollup.agg_subquery(include_resumed=True) + query.apply_show_state_filter instead of a third hand-inlined copy of the per-show rollup + in_progress predicate, so the Discover rail can't drift from the grid's definition. Test coverage (the gaps a regression would ship through): - resolve_library_guids scope-exclusion (a credit in a non-scoped/disabled library is dropped) + the []-vs-None distinction; parse_person cross-media-id retention; enrich_person transient (NULL watermark) and 404 (stamp) branches; _enrich_person_bg body (fresh session close/rollback, None-guard) + the cooldown dedup; continue_watching fully-watched-show exclusion; Combobox Enter-commits-highlighted + async/controlled-query mode; PopoverItem aria-checked contract; RatingsRow missing-IMDb + imdb==audience branches. Backend lane 413→420, FE vitest 351→357; all gates green. No migration.
203 lines
8.5 KiB
Python
203 lines
8.5 KiB
Python
"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail
|
|
payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB,
|
|
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
|
|
without Postgres.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.state as state
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.main import app
|
|
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
|
|
|
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
def _seed(db):
|
|
"""A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2
|
|
(in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode."""
|
|
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
|
db.add_all([lib_m, lib_s])
|
|
db.flush()
|
|
|
|
m1 = PlexItem(
|
|
rating_key="m1", library_id=lib_m.id, kind="movie", title="Anchor Movie",
|
|
guid="tmdb:111", genres=["Drama"], year=2001,
|
|
added_at=NOW - timedelta(days=1),
|
|
tmdb_enriched_at=NOW - timedelta(days=1),
|
|
recommendations=[
|
|
{"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"}
|
|
],
|
|
)
|
|
m2 = PlexItem(
|
|
rating_key="m2", library_id=lib_m.id, kind="movie", title="Rec Movie",
|
|
guid="tmdb:555", genres=["Comedy"], year=2002,
|
|
added_at=NOW - timedelta(days=2),
|
|
)
|
|
m3 = PlexItem(
|
|
rating_key="m3", library_id=lib_m.id, kind="movie", title="Half-Watched Movie",
|
|
guid="tmdb:333", genres=["Drama"], year=2003, duration_s=6000,
|
|
added_at=NOW - timedelta(days=3),
|
|
)
|
|
sh1 = PlexShow(rating_key="sh1", library_id=lib_s.id, title="A Show", child_count=1,
|
|
added_at=NOW - timedelta(hours=1)) # newest → leads recently-added
|
|
db.add_all([m1, m2, m3, sh1])
|
|
db.flush()
|
|
ep1 = PlexItem(
|
|
rating_key="sh1e1", library_id=lib_s.id, kind="episode", title="Ep 1",
|
|
show_id=sh1.id, season_number=1, episode_number=1,
|
|
)
|
|
ep2 = PlexItem(
|
|
rating_key="sh1e2", library_id=lib_s.id, kind="episode", title="Ep 2",
|
|
show_id=sh1.id, season_number=1, episode_number=2,
|
|
)
|
|
db.add_all([ep1, ep2])
|
|
|
|
user = User(email="discover@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
db.add_all([
|
|
# m1 watched most recently → the because-you-watched anchor.
|
|
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
|
|
# m3 in-progress → continue watching.
|
|
PlexState(user_id=user.id, item_id=m3.id, status="new", position_seconds=1200,
|
|
progress_updated_at=NOW - timedelta(hours=1)),
|
|
# sh1's ep1 in-progress → the show is in continue watching.
|
|
PlexState(user_id=user.id, item_id=ep1.id, status="new", position_seconds=300,
|
|
progress_updated_at=NOW - timedelta(minutes=30)),
|
|
])
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
def _get(db, user, monkeypatch, path):
|
|
monkeypatch.setattr(state, "_configured_cache", True)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
app.dependency_overrides[current_user] = lambda: user
|
|
try:
|
|
client = TestClient(app) # no context manager: skip lifespan
|
|
return client.get(path)
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _ids(cards):
|
|
return [c["id"] for c in cards]
|
|
|
|
|
|
def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
|
user = _seed(db)
|
|
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both")
|
|
assert resp.status_code == 200, resp.text
|
|
d = resp.json()
|
|
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
|
|
# Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3.
|
|
assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"]
|
|
# Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2).
|
|
genres = {g["value"]: g["count"] for g in d["genres"]}
|
|
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
|
|
|
|
|
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
|
user = _seed(db)
|
|
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
|
ids = _ids(d["continue_watching"])
|
|
# The in-progress movie and the show with an in-progress episode are both present; the watched
|
|
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
|
|
assert ids == ["sh1", "m3"]
|
|
assert "m1" not in ids
|
|
|
|
|
|
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
|
|
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
|
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
|
# A hidden started movie must be excluded.
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
m_null = PlexItem(rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1")
|
|
m_hidden = PlexItem(rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2")
|
|
db.add_all([m_null, m_hidden])
|
|
db.flush()
|
|
user = User(email="cw-null@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
db.add_all([
|
|
PlexState(user_id=user.id, item_id=m_null.id, status=None, position_seconds=500,
|
|
progress_updated_at=NOW),
|
|
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden", position_seconds=500,
|
|
progress_updated_at=NOW - timedelta(hours=1)),
|
|
])
|
|
db.commit()
|
|
|
|
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"])
|
|
assert "mn" in ids # NULL-status started movie IS in progress
|
|
assert "mh" not in ids # hidden is excluded
|
|
|
|
|
|
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
|
|
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
|
|
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
|
|
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
|
db.add(lib_s)
|
|
db.flush()
|
|
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
|
|
db.add(sh)
|
|
db.flush()
|
|
ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1",
|
|
show_id=sh.id, season_number=1, episode_number=1)
|
|
db.add(ep)
|
|
db.flush()
|
|
user = User(email="fullywatched@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0,
|
|
progress_updated_at=NOW))
|
|
db.commit()
|
|
|
|
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"])
|
|
assert "shw" not in ids # fully watched → excluded from continue-watching
|
|
|
|
|
|
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
|
|
user = _seed(db)
|
|
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
|
bw = d["because_you_watched"]
|
|
assert bw is not None
|
|
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
|
|
# m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card.
|
|
assert _ids(bw["items"]) == ["m2"]
|
|
|
|
|
|
def test_because_you_watched_null_without_history(db, monkeypatch):
|
|
_seed(db)
|
|
fresh = User(email="fresh-discover@example.com")
|
|
db.add(fresh)
|
|
db.commit()
|
|
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json()
|
|
# A user with no watched-and-enriched movie gets no recommendation rail.
|
|
assert d["because_you_watched"] is None
|
|
assert d["continue_watching"] == []
|
|
|
|
|
|
def test_surprise_scope_and_bound(db, monkeypatch):
|
|
user = _seed(db)
|
|
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2")
|
|
assert resp.status_code == 200, resp.text
|
|
items = resp.json()["items"]
|
|
assert len(items) <= 2
|
|
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
|
|
|
|
|
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
|
|
user = _seed(db)
|
|
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
|
|
assert all(c["type"] == "movie" for c in d["recently_added"])
|
|
# The show drops out of continue watching under the movie scope.
|
|
assert _ids(d["continue_watching"]) == ["m3"]
|