Discover now behaves like the Browse grid — the active metadata filters (genre / rating / year / collection / studio / …) narrow every rail: Continue watching, Recently added, Surprise me, the genre tiles, and Because you watched. Same LibraryFilters / meta_filter_conds the grid uses, applied to each rail's movie + show query (movie duration included); a rail may come back empty, which the client already hides. Genre-language matches the grid. Because Discover honours filters itself, adding a filter no longer force-flips the Discover landing to the grid (only a header SEARCH does, since Discover has no search rail) — so you stay on Discover with narrowed rails. Frontend threads `filters` into api.plexDiscover/plexSurprise + their query keys (plexSurprise is variadic now) and the watched-toggle setQueryData; PlexDiscover reads them from usePlex. Tests: two discover filter-narrowing cases; PlexDiscover dom test updated for the provider default + 3-arg call.
227 lines
9.8 KiB
Python
227 lines
9.8 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"]
|
|
|
|
|
|
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"]
|