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:
@@ -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:<id>`) 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 [],
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]}
|
||||
@@ -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"]
|
||||
@@ -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<Sub>({ 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<Sub>({ 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 = (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={sub.view.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sub.view.kind === "discover") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<div className="mb-4 flex items-center">{viewToggle}</div>
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||
{viewToggle}
|
||||
<p className="text-xs text-muted">
|
||||
{browseQ.isLoading
|
||||
? " "
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
// Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name.
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={onGenre}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.empty");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{title}
|
||||
{action}
|
||||
</span>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
|
||||
{cards.map((c) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlexDiscoverData>(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 (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading || !q.data) {
|
||||
return <LoadingState label={t("plex.loading")} />;
|
||||
}
|
||||
|
||||
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 <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
|
||||
}
|
||||
|
||||
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{d.continue_watching.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.continueWatching")}
|
||||
ariaLabel={t("plex.discover.continueWatching")}
|
||||
cards={d.continue_watching}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_added.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyAdded")}
|
||||
ariaLabel={t("plex.discover.recentlyAdded")}
|
||||
onSeeAll={onSeeAllRecent}
|
||||
cards={d.recently_added}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{surprise.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.surprise")}
|
||||
ariaLabel={t("plex.discover.surprise")}
|
||||
cards={surprise}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surpriseQ.refetch()}
|
||||
aria-label={t("plex.discover.reshuffle")}
|
||||
title={t("plex.discover.reshuffle")}
|
||||
className="rounded-full p-1 text-muted transition hover:text-accent"
|
||||
>
|
||||
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.genres.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g) => {
|
||||
const hue = genreHue(g.value);
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bw && bw.items.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
cards={bw.items}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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=<kind>:<args>", () => {
|
||||
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" },
|
||||
|
||||
@@ -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=<kind>:<args> 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<Sub> = {
|
||||
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<Sub> = {
|
||||
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<Sub> = {
|
||||
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":
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlexDiscover> => {
|
||||
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<PlexShowDetail> =>
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user