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