The Surprise rail drew a random 20+20 sample including already-watched titles. Its point is to surface something to WATCH, so exclude seen titles (a watched movie, a fully-watched show — a partially-watched or never-started show still counts as unseen) and cap at 3 movies + 3 shows (6 total), interleaved. Verified live: /surprise returns 6 items, 3+3, none watched. Gate: backend ruff clean, 12 discover tests (+ a watched-exclusion/cap assertion).
249 lines
11 KiB
Python
249 lines
11 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_genre_tiles_carry_poster_art(db, monkeypatch):
|
||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||
assert art["Comedy"] == ["m2"]
|
||
|
||
|
||
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_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
|
||
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
|
||
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||
user = _seed(db)
|
||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"]
|
||
ids = _ids(items)
|
||
assert "m1" not in ids # watched → excluded
|
||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
||
assert len([c for c in items if c["type"] == "movie"]) <= 3
|
||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||
|
||
|
||
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"]
|
||
|
||
|
||
def test_filters_narrow_every_discover_rail(db, monkeypatch):
|
||
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
|
||
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||
user = _seed(db)
|
||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json()
|
||
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
|
||
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
||
assert _ids(d["continue_watching"]) == ["m3"]
|
||
# Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
|
||
assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
|
||
# Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
|
||
assert d["because_you_watched"] is None
|
||
|
||
|
||
def test_filters_narrow_surprise(db, monkeypatch):
|
||
user = _seed(db)
|
||
items = _get(
|
||
db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||
).json()["items"]
|
||
# Only the one Comedy movie qualifies under the filter.
|
||
assert _ids(items) == ["m2"]
|