E5 S4: Plex Discover rail-surface (default landing)
A dedicated Plex "Discover" landing — a stack of horizontal Carousel rails served from ONE consolidated read (serve-from-DB, no live Plex/TMDB call): - GET /api/plex/discover → continue_watching (in-progress movies + shows, most recently progressed) · recently_added · genres (top-N tiles) · because_you_watched (recs of the most-recent watched, TMDB-enriched movie, resolved to library rows by guid via serve_block's resolver). - GET /api/plex/surprise → a fresh random, scope-bounded sample for the ↻ rail. Frontend: PlexDiscover.tsx (rails reuse the S1 Carousel + S3 PlexPosterCard; colored genre tiles → tap to filter the grid; ↻ reshuffles the surprise rail alone). Discover is now the root sub-view; the flat grid becomes the bookmarkable "Browse all" secondary view (?v=browse) reached by a header search/filter (a browse-intent flip) or the new Discover⟷Browse toggle. resolve_recs is promoted from _resolve_recs (now cross-module). Tests: test_plex_discover.py (rail shape/order, continue-watching, because-you-watched + null, surprise bounds, scope) and PlexDiscover.dom.test.tsx; plexSubUrl round-trip updated for discover-root/grid-browse. Backend lane 393, vitest 332.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"""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_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"]
|
||||
Reference in New Issue
Block a user