From f9b63745f9efc3b0320537cf08b4f4307a2bf8da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 22:15:54 +0200 Subject: [PATCH] E5 S4: Plex Discover rail-surface (default landing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/plex/tmdb.py | 4 +- backend/app/routes/plex/__init__.py | 2 + backend/app/routes/plex/discover.py | 331 ++++++++++++++++++ backend/tests/test_plex_discover.py | 151 ++++++++ frontend/src/components/PlexBrowse.tsx | 51 ++- .../src/components/PlexDiscover.dom.test.tsx | 181 ++++++++++ frontend/src/components/PlexDiscover.tsx | 235 +++++++++++++ frontend/src/components/plexSubUrl.test.ts | 6 +- frontend/src/components/plexSubUrl.ts | 13 +- frontend/src/i18n/locales/en/plex.json | 14 + frontend/src/i18n/locales/hu/plex.json | 14 + frontend/src/lib/api/plex.ts | 23 ++ frontend/src/lib/queryKeys.ts | 6 + 13 files changed, 1022 insertions(+), 9 deletions(-) create mode 100644 backend/app/routes/plex/discover.py create mode 100644 backend/tests/test_plex_discover.py create mode 100644 frontend/src/components/PlexDiscover.dom.test.tsx create mode 100644 frontend/src/components/PlexDiscover.tsx diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index ec12c9d3..5779664e 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -334,7 +334,7 @@ def run_enrich(db: Session) -> dict: return {"enriched": n} -def _resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: +def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:`) and, if so, its rating_key — so the client can deep-link the ones we have and merely display the rest.""" if not recs: @@ -374,6 +374,6 @@ def serve_block(db: Session, row) -> dict | None: "crew": row.crew or [], "studios": row.studios or [], "production_countries": row.production_countries or [], - "recommendations": _resolve_recs(db, row.recommendations), + "recommendations": resolve_recs(db, row.recommendations), "backdrops": row.backdrops or [], } diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index 60fbfc88..ce713a10 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -15,6 +15,7 @@ from app.routes.plex import ( browse, collections, detail, + discover, people, playback, playlists, @@ -25,6 +26,7 @@ router.include_router(admin.router) router.include_router(browse.router) router.include_router(collections.router) router.include_router(detail.router) +router.include_router(discover.router) router.include_router(people.router) router.include_router(playback.router) router.include_router(playlists.router) diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py new file mode 100644 index 00000000..be50e250 --- /dev/null +++ b/backend/app/routes/plex/discover.py @@ -0,0 +1,331 @@ +"""Plex Discover: the rail-surface read-model (E5 S4). + +A consolidated payload for the dedicated Discover landing — a stack of horizontal rails: +Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a +separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the +recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and +every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit +S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid +card paths); the HU overlay coalesces only in the main grid. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.auth import current_user +from app.db import get_db +from app.models import PlexItem, PlexShow, PlexState, User +from app.plex import rollup +from app.plex.cards import movie_card, show_card +from app.plex.query import genres_col +from app.plex.tmdb import resolve_recs +from app.routes.plex._shared import _content_libs, _norm_lang + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + +RAIL_LIMIT = 20 +GENRE_TILES = 12 + + +def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]: + """The movie/show library ids visible for this scope (empty list = out of scope).""" + movie_ids, show_ids = _content_libs(db) + return ( + movie_ids if scope in ("movie", "both") else [], + show_ids if scope in ("show", "both") else [], + ) + + +def _movie_states(db: Session, uid: int, items: list[PlexItem]) -> dict[int, PlexState]: + """One batched fetch of the user's watch-state for a set of movies (avoids a per-card query).""" + ids = [it.id for it in items] + if not ids: + return {} + rows = db.execute( + select(PlexState).where(PlexState.user_id == uid, PlexState.item_id.in_(ids)) + ).scalars().all() + return {r.item_id: r for r in rows} + + +def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]: + sts = _movie_states(db, uid, items) + return [movie_card(it, sts.get(it.id)) for it in items] + + +def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]: + statuses = rollup.state_map(db, uid, [s.id for s in shows]) + return [show_card(s, statuses.get(s.id, "new")) for s in shows] + + +def _recently_added( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> list[dict]: + """Newest movies + shows, interleaved by `added_at` desc into one rail.""" + movies = ( + db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)) + .order_by(PlexItem.added_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).scalars().all() + if movie_ids + else [] + ) + shows = ( + db.execute( + select(PlexShow) + .where(PlexShow.library_id.in_(show_ids)) + .order_by(PlexShow.added_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).scalars().all() + if show_ids + else [] + ) + # Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the + # per-card state lookups stay batched (no N+1). The `(is not None, value)` key sorts a missing + # timestamp last under reverse. + tagged = [(m.added_at, "movie", m) for m in movies] + [ + (s.added_at, "show", s) for s in shows + ] + tagged.sort(key=lambda t: (t[0] is not None, t[0]), reverse=True) + top = tagged[:RAIL_LIMIT] + mcards = { + id(m): c + for m, c in zip( + [t[2] for t in top if t[1] == "movie"], + _movie_cards(db, uid, [t[2] for t in top if t[1] == "movie"]), + ) + } + scards = { + id(s): c + for s, c in zip( + [t[2] for t in top if t[1] == "show"], + _show_cards(db, uid, [t[2] for t in top if t[1] == "show"]), + ) + } + return [mcards[id(o)] if kind == "movie" else scards[id(o)] for _, kind, o in top] + + +def _continue_watching( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> list[dict]: + """Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first. + A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes.""" + rows: list[tuple[object, object, dict]] = [] # (resumed_at, sentinel-ordinal, card) + + if movie_ids: + mrows = db.execute( + select(PlexItem, PlexState) + .join( + PlexState, + and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid), + ) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_ids), + PlexState.position_seconds > 0, + # A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) — + # `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE. + or_( + PlexState.status.is_(None), + PlexState.status.notin_(["watched", "hidden"]), + ), + ) + .order_by(PlexState.progress_updated_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).all() + for it, st in mrows: + rows.append((st.progress_updated_at, movie_card(it, st))) + + if show_ids: + ps = aliased(PlexState) + prog = ( + select( + PlexItem.show_id.label("show_id"), + func.max(ps.progress_updated_at).label("resumed_at"), + func.count(PlexItem.id).label("total"), + func.count(case((ps.status == "watched", 1))).label("watched"), + func.count( + case( + ( + and_( + ps.position_seconds > 0, + or_(ps.status.is_(None), ps.status != "watched"), + ), + 1, + ) + ) + ).label("inprog"), + ) + .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == uid)) + .where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) + .group_by(PlexItem.show_id) + .subquery() + ) + # in-progress == started but not fully watched (mirrors apply_show_state_filter's `in_progress`). + srows = db.execute( + select(PlexShow, prog.c.resumed_at) + .join(prog, prog.c.show_id == PlexShow.id) + .where( + PlexShow.library_id.in_(show_ids), + or_(prog.c.watched > 0, prog.c.inprog > 0), + prog.c.watched < prog.c.total, + ) + .order_by(prog.c.resumed_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).all() + for sh, resumed_at in srows: + rows.append((resumed_at, show_card(sh, "in_progress"))) + + rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True) + return [card for _, card in rows[:RAIL_LIMIT]] + + +def _genre_tiles( + db: Session, movie_ids: list[int], show_ids: list[int], lang: str +) -> list[dict]: + """The most-common genres across the scope, as {value, count} tiles → tap to filter the grid. + The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.""" + counts: dict[str, int] = {} + for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)): + if not ids: + continue + gcol = genres_col(model, lang) + base = select(func.jsonb_array_elements_text(gcol).label("g")).where( + model.library_id.in_(ids), func.jsonb_typeof(gcol) == "array" + ) + if model is PlexItem: + base = base.where(model.kind == "movie") + expanded = base.subquery() + for g, c in db.execute( + select(expanded.c.g, func.count()).group_by(expanded.c.g) + ).all(): + counts[g] = counts.get(g, 0) + c + top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES] + return [{"value": g, "count": c} for g, c in top] + + +def _because_you_watched( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> dict | None: + """Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the + recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns + None (rail hidden) when there's no enriched watch history or none of its recs are in the library.""" + if not movie_ids: + return None + anchor = db.execute( + select(PlexItem) + .join( + PlexState, + and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid), + ) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_ids), + PlexState.status == "watched", + PlexItem.recommendations.isnot(None), + ) + .order_by(PlexState.watched_at.desc().nullslast()) + .limit(1) + ).scalars().first() + if anchor is None: + return None + resolved = resolve_recs(db, anchor.recommendations) + # Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself. + rks = [ + r["rating_key"] + for r in resolved + if r.get("in_library") and r.get("rating_key") != anchor.rating_key + ] + if not rks: + return None + movies = { + m.rating_key: m + for m in db.execute( + select(PlexItem).where( + PlexItem.kind == "movie", PlexItem.rating_key.in_(rks) + ) + ).scalars().all() + } + shows = { + s.rating_key: s + for s in db.execute( + select(PlexShow).where(PlexShow.rating_key.in_(rks)) + ).scalars().all() + } + ordered_movies = [movies[rk] for rk in rks if rk in movies] + ordered_shows = [shows[rk] for rk in rks if rk in shows] + mcards = {id(m): c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))} + scards = {id(s): c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))} + items: list[dict] = [] + for rk in rks: + if rk in movies: + items.append(mcards[id(movies[rk])]) + elif rk in shows: + items.append(scards[id(shows[rk])]) + if not items: + return None + return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items} + + +@router.get("/discover") +def discover( + scope: str = "both", + lang: str = "en", + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as + empty lists (the client hides them); `because_you_watched` is null when there's no anchor.""" + lang = _norm_lang(lang) + movie_ids, show_ids = _scope_libs(db, scope) + return { + "scope": scope, + "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids), + "recently_added": _recently_added(db, user.id, movie_ids, show_ids), + "genres": _genre_tiles(db, movie_ids, show_ids, lang), + "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids), + } + + +@router.get("/surprise") +def surprise( + scope: str = "both", + limit: int = Query(default=RAIL_LIMIT, ge=1, le=40), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and + shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type.""" + movie_ids, show_ids = _scope_libs(db, scope) + movies = ( + db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)) + .order_by(func.random()) + .limit(limit) + ).scalars().all() + if movie_ids + else [] + ) + shows = ( + db.execute( + select(PlexShow) + .where(PlexShow.library_id.in_(show_ids)) + .order_by(func.random()) + .limit(limit) + ).scalars().all() + if show_ids + else [] + ) + mcards = _movie_cards(db, user.id, movies) + scards = _show_cards(db, user.id, shows) + # Interleave movie/show cards, then trim to `limit`. + items: list[dict] = [] + for i in range(max(len(mcards), len(scards))): + if i < len(mcards): + items.append(mcards[i]) + if i < len(scards): + items.append(scards[i]) + return {"scope": scope, "items": items[:limit]} diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py new file mode 100644 index 00000000..5eb656b4 --- /dev/null +++ b/backend/tests/test_plex_discover.py @@ -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"] diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 545d4770..de290cb5 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -25,6 +25,8 @@ import { useHistorySubview } from "../lib/history"; import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { toggleWatchState } from "../lib/plexWatch"; import SortControl from "./SortControl"; +import SegmentedToggle from "./SegmentedToggle"; +import PlexDiscover from "./PlexDiscover"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import { StateMessage } from "./QueryState"; @@ -70,7 +72,9 @@ export default function PlexBrowse() { const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); - const sub = useHistorySubview({ kind: "grid" }, PLEX_SUB_URL); + // Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search + // or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns. + const sub = useHistorySubview({ kind: "discover" }, PLEX_SUB_URL); // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then // clear the signal so it doesn't re-open. @@ -95,6 +99,19 @@ export default function PlexBrowse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [jumpTo]); + // Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on + // the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge, + // so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a + // Back to Discover with the search still in the box doesn't immediately re-flip. + const browseIntent = !!dq || plexFilterCount(filters) > 0; + const prevIntent = useRef(browseIntent); + useEffect(() => { + if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) { + sub.open({ kind: "grid" }); + } + prevIntent.current = browseIntent; + }, [browseIntent, sub]); + // Opening a show/player replaces the grid entirely (the component returns early below), so the // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and // restore it when we come back — so the browser/mouse Back from the player lands on the same card @@ -124,7 +141,7 @@ export default function PlexBrowse() { const tag = (el?.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return; - if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { + if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { e.preventDefault(); sub.back(); } @@ -337,12 +354,42 @@ export default function PlexBrowse() { ); } + // Discover ⟷ Browse switch, shown on both surfaces so either can return to the other. + const viewToggle = ( + sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })} + /> + ); + + if (sub.view.kind === "discover") { + return ( +
+
{viewToggle}
+ applyFilter({ genres: [g] })} + onSeeAllRecent={() => sub.open({ kind: "grid" })} + /> +
+ ); + } + return (
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */}
+ {viewToggle}

{browseQ.isLoading ? " " diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx new file mode 100644 index 00000000..7a204232 --- /dev/null +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom +// +// E5 S4: the Plex Discover landing. Covers the rail surface served from the mocked /discover + +// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added +// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty +// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve +// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + // Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name. + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.title ? `${key}:${opts.title}` : key, + }), + }; +}); + +const plexDiscover = vi.fn(); +const plexSurprise = vi.fn(); +const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve()); +vi.mock("../lib/api", () => ({ + api: { + plexDiscover: (...a: unknown[]) => plexDiscover(...a), + plexSurprise: (...a: unknown[]) => plexSurprise(...a), + plexSetState: (...a: unknown[]) => plexSetState(...a), + }, +})); + +import PlexDiscover from "./PlexDiscover"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({ + id, + type, + title, + year: 2000, + duration_seconds: 3600, + thumb: `/api/plex/image/${id}`, + playable: "direct", + status: "new", + position_seconds: 0, +}); + +const fullPayload = { + scope: "both", + continue_watching: [card("cw1", "Resume Me")], + recently_added: [card("ra1", "Just Added")], + genres: [{ value: "Drama", count: 12 }], + because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, +}; + +function wrap(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +const noop = () => {}; + +describe("PlexDiscover", () => { + it("renders each non-empty rail, the genre tiles, and the surprise sample", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + wrap( + , + ); + await screen.findByText("plex.discover.continueWatching"); + expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy(); + expect(screen.getByText("plex.discover.surprise")).toBeTruthy(); + expect(screen.getByText("plex.discover.byGenre")).toBeTruthy(); + // The because-you-watched heading interpolates the anchor title. + expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy(); + // A card from each rail is present (title rendered in the poster label). + expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0); + expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0); + expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0); + expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0); + expect(plexDiscover).toHaveBeenCalledWith("both", "en"); + }); + + it("fires onGenre with the tile's value", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + const onGenre = vi.fn(); + wrap( + , + ); + fireEvent.click(await screen.findByText("Drama")); + expect(onGenre).toHaveBeenCalledWith("Drama"); + }); + + it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + const onSeeAllRecent = vi.fn(); + wrap( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); + expect(onSeeAllRecent).toHaveBeenCalledOnce(); + }); + + it("reshuffles the surprise rail alone (refetch) without refetching the surface", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + wrap( + , + ); + await screen.findByText("Random Pick"); + expect(plexSurprise).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" })); + await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2)); + expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched + }); + + it("shows the empty message when every rail is empty", async () => { + plexDiscover.mockResolvedValue({ + scope: "both", + continue_watching: [], + recently_added: [], + genres: [], + because_you_watched: null, + }); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + wrap( + , + ); + await screen.findByText("plex.discover.empty"); + }); +}); diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx new file mode 100644 index 00000000..055184e4 --- /dev/null +++ b/frontend/src/components/PlexDiscover.tsx @@ -0,0 +1,235 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { RotateCcw } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api"; +import { plexLang } from "../i18n"; +import { toggleWatchState } from "../lib/plexWatch"; +import { Carousel } from "./Carousel"; +import { PlexPosterCard } from "./plexCards"; +import { LoadingState, StateMessage } from "./QueryState"; + +// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE +// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you +// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its +// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click / +// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like +// a grid card; the watched-toggle optimistically flips THIS surface's cache too. + +// A stable, pleasant hue per genre string (no art to collage yet — colored tiles stand in). Hashing +// keeps a genre the same color across renders/sessions without storing anything. +function genreHue(name: string): number { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; + return h; +} + +function CardRail({ + title, + ariaLabel, + seeAllLabel, + onSeeAll, + cards, + onOpenCard, + onInfoCard, + onToggleWatched, + action, +}: { + title: ReactNode; + ariaLabel: string; + seeAllLabel?: string; + onSeeAll?: () => void; + cards: PlexCard[]; + onOpenCard: (c: PlexCard) => void; + onInfoCard: (c: PlexCard) => void; + onToggleWatched: (c: PlexCard) => void; + action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button) +}) { + const heading = + action != null ? ( + + {title} + {action} + + ) : ( + title + ); + return ( +

+ + {cards.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={onToggleWatched} + /> +
+ ))} +
+
+ ); +} + +export default function PlexDiscover({ + scope, + onOpenCard, + onInfoCard, + onGenre, + onSeeAllRecent, +}: { + scope: string; + onOpenCard: (c: PlexCard) => void; + onInfoCard: (c: PlexCard) => void; + onGenre: (genre: string) => void; + onSeeAllRecent: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexDiscover(scope, lang), + queryFn: () => api.plexDiscover(scope, lang), + }); + // Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface. + const surpriseQ = useQuery({ + queryKey: qk.plexSurprise(scope), + queryFn: () => api.plexSurprise(scope), + staleTime: 0, + }); + + // Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise + // list, then persist + resync via the shared helper — invalidating the grid too so it agrees. + const toggleWatched = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => { + const flip = (cards: PlexCard[]) => + cards.map((c) => (c.id === card.id ? { ...c, status: next } : c)); + qc.setQueryData(qk.plexDiscover(scope, lang), (old) => + old + ? { + ...old, + continue_watching: flip(old.continue_watching), + recently_added: flip(old.recently_added), + because_you_watched: old.because_you_watched + ? { ...old.because_you_watched, items: flip(old.because_you_watched.items) } + : null, + } + : old, + ); + qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) => + old ? { ...old, items: flip(old.items) } : old, + ); + }, + resync: () => { + qc.invalidateQueries({ queryKey: qk.plexDiscover() }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + }, + }); + + if (q.isError && !q.data) { + return ( + q.refetch()}> + {t("common.loadError")} + + ); + } + if (q.isLoading || !q.data) { + return ; + } + + const d = q.data; + const surprise = surpriseQ.data?.items ?? []; + const bw = d.because_you_watched; + const nothing = + d.continue_watching.length === 0 && + d.recently_added.length === 0 && + d.genres.length === 0 && + surprise.length === 0 && + (!bw || bw.items.length === 0); + + if (nothing) { + return

{t("plex.discover.empty")}

; + } + + const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched }; + + return ( +
+ {d.continue_watching.length > 0 && ( + + )} + + {d.recently_added.length > 0 && ( + + )} + + {surprise.length > 0 && ( + surpriseQ.refetch()} + aria-label={t("plex.discover.reshuffle")} + title={t("plex.discover.reshuffle")} + className="rounded-full p-1 text-muted transition hover:text-accent" + > + + + } + {...railProps} + /> + )} + + {d.genres.length > 0 && ( +
+

{t("plex.discover.byGenre")}

+
+ {d.genres.map((g) => { + const hue = genreHue(g.value); + return ( + + ); + })} +
+
+ )} + + {bw && bw.items.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index 5a153802..4ed2f3fc 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -5,6 +5,7 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl"; describe("PLEX_SUB_URL encode", () => { it("mirrors each bookmarkable sub-view to ?v=:", () => { + expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4) expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1"); expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2"); expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9"); @@ -12,14 +13,15 @@ describe("PLEX_SUB_URL encode", () => { expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12"); }); - it("does NOT mirror the root grid or the ephemeral player (encode → null)", () => { - expect(U.encode({ kind: "grid" })).toBeNull(); + it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => { + expect(U.encode({ kind: "discover" })).toBeNull(); expect(U.encode({ kind: "player", id: "m9" })).toBeNull(); }); }); describe("PLEX_SUB_URL round-trip", () => { const cases: Sub[] = [ + { kind: "grid" }, { kind: "show", id: "s1" }, { kind: "season", showId: "s1", seasonId: "se2" }, { kind: "info", id: "m9" }, diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index a63e633a..381a7bd2 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -5,6 +5,7 @@ import { parsePositiveIntId } from "../lib/parseId"; // encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component // (and its lazy hls.js player) into the test graph. export type Sub = + | { kind: "discover" } | { kind: "grid" } | { kind: "show"; id: string } | { kind: "season"; showId: string; seasonId: string } @@ -14,12 +15,16 @@ export type Sub = | { kind: "playlist"; id: number }; // Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page -// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person -// name is the whole tail after the first ":", so a name containing ":" round-trips. +// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the +// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a +// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing +// ":" round-trips. export const PLEX_SUB_URL: SubviewUrl = { key: "v", encode: (v) => { switch (v.kind) { + case "grid": + return "browse"; case "show": return `show:${v.id}`; case "season": @@ -31,7 +36,7 @@ export const PLEX_SUB_URL: SubviewUrl = { case "playlist": return `playlist:${v.id}`; default: - return null; // grid + the ephemeral player are not URL-mirrored + return null; // discover (root) + the ephemeral player are not URL-mirrored } }, decode: (raw) => { @@ -39,6 +44,8 @@ export const PLEX_SUB_URL: SubviewUrl = { const kind = i === -1 ? raw : raw.slice(0, i); const arg = i === -1 ? "" : raw.slice(i + 1); switch (kind) { + case "browse": + return { kind: "grid" }; case "show": return arg ? { kind: "show", id: arg } : null; case "info": diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index f4cd4060..c9b3d2b2 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -253,5 +253,19 @@ "movies": "Movies ({{count}})", "shows": "Shows ({{count}})", "empty": "No titles found for this person." + }, + "view": { + "label": "View", + "browse": "Browse" + }, + "discover": { + "tab": "Discover", + "continueWatching": "Continue watching", + "recentlyAdded": "Recently added", + "surprise": "Surprise me", + "reshuffle": "Reshuffle", + "byGenre": "Browse by genre", + "becauseWatched": "Because you watched {{title}}", + "empty": "Nothing to discover yet — run a Plex sync from the admin Config page." } } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index eeb7dd58..2039b5c9 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -253,5 +253,19 @@ "movies": "Filmek ({{count}})", "shows": "Sorozatok ({{count}})", "empty": "Nincs találat ehhez a személyhez." + }, + "view": { + "label": "Nézet", + "browse": "Böngészés" + }, + "discover": { + "tab": "Felfedezés", + "continueWatching": "Folytatás", + "recentlyAdded": "Nemrég hozzáadva", + "surprise": "Lepj meg", + "reshuffle": "Újrakeverés", + "byGenre": "Böngészés műfaj szerint", + "becauseWatched": "Mert megnézted: {{title}}", + "empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról." } } diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 442a9ed5..47e8a5ff 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -263,6 +263,17 @@ export interface PlexPlaySession { start_s: number; } +// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the +// dedicated Discover landing renders as a horizontal Carousel; `because_you_watched` is null when the +// user has no TMDB-enriched watch history (rail hidden). Served from DB — no live Plex/TMDB call. +export interface PlexDiscover { + scope: string; + continue_watching: PlexCard[]; + recently_added: PlexCard[]; + genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid + because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null; +} + // Two-way Plex watch-state sync link status (owner account; Phase A). export interface PlexWatchLink { linked: boolean; @@ -411,6 +422,18 @@ export const plexApi = { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }), }), + // Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary. + plexDiscover: (scope: string, lang?: string): Promise => { + const u = new URLSearchParams({ scope }); + if (lang === "hu") u.set("lang", "hu"); + return req(`/api/plex/discover?${u.toString()}`); + }, + // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch). + plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => { + const u = new URLSearchParams({ scope }); + if (limit) u.set("limit", String(limit)); + return req(`/api/plex/surprise?${u.toString()}`); + }, plexShow: (id: string, lang?: string): Promise => req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`), // Mark a whole show / season watched or unwatched (all its episodes) for the current user. diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index ee1cfaf5..08d69d50 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -93,6 +93,12 @@ export const qk = { plex: () => ["plex"] as const, plexWatchLink: () => ["plex-watch-link"] as const, plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], + // Discover surface (E5 S4): the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches + // both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle + // refetches it alone. + plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], + plexSurprise: (scope?: string) => + scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const), plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.