fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn
Show cards carry resumable (ShowState, from the same rollup) so a part-watched show whose next episode is untouched offers Play, not a Continue and a Restart that do the same thing; plexStarted is the one rule for cards and menus. The Surprise rail's key sits under the discover root: whatever refreshes the rails re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc, and only the reshuffle deals. Closing the player refreshes the Discover rails. A show card's play acts on the last click only, and only where it was made.
This commit is contained in:
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.discover import cards, queries
|
||||
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, show_card
|
||||
from app.plex.cards import SHOW_NEW, leaf_card, show_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
|
||||
@@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
|
||||
sh = db.get(PlexShow, pin.plex_show_id)
|
||||
if sh is None:
|
||||
return None
|
||||
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
|
||||
card = cards.from_plex(show_card(sh, status, lang=lang))
|
||||
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
|
||||
card = cards.from_plex(show_card(sh, state, lang=lang))
|
||||
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
|
||||
nxt = cards.from_plex(ep) if ep else None
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ from app.models import (
|
||||
VideoState,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.cards import ShowState, display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.query import apply_show_state_filter, content_libs
|
||||
|
||||
# How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
|
||||
@@ -256,16 +256,19 @@ def plex_in_progress(
|
||||
if show_libs:
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_libs))
|
||||
for sh, resumed_at in db.execute(
|
||||
for sh, resumed_at, inprog in db.execute(
|
||||
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
|
||||
).all():
|
||||
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
|
||||
state = ShowState("in_progress", (inprog or 0) > 0)
|
||||
out.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@@ -3,9 +3,24 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the
|
||||
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||
router. Pure functions — no DB, no request state."""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.models import PlexItem, PlexShow, PlexState
|
||||
|
||||
|
||||
class ShowState(NamedTuple):
|
||||
"""A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched
|
||||
episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A
|
||||
show part watched with its next episode untouched has none: Continue and Restart would both play
|
||||
that episode from its start, so the card offers Play alone."""
|
||||
|
||||
status: str
|
||||
resumable: bool
|
||||
|
||||
|
||||
SHOW_NEW = ShowState("new", False)
|
||||
|
||||
|
||||
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||
@@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
||||
def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict:
|
||||
return {
|
||||
"id": sh.rating_key,
|
||||
"type": "show",
|
||||
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
|
||||
"art": _art_url(sh.rating_key, sh.art_key),
|
||||
"season_count": sh.child_count,
|
||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||
"status": status,
|
||||
"status": state.status,
|
||||
"resumable": state.resumable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app.models import PlexItem, PlexState
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
|
||||
# same at every rail that merges two type-homogeneous lists in Python.
|
||||
@@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str:
|
||||
return "new"
|
||||
|
||||
|
||||
def show_state(total: int, watched: int, inprog: int) -> ShowState:
|
||||
"""`show_status` plus whether an episode holds a resume point (the card's `ShowState`)."""
|
||||
return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0)
|
||||
|
||||
|
||||
def rollup(cards: list[dict]) -> dict:
|
||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||
@@ -92,11 +98,11 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
)
|
||||
|
||||
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]:
|
||||
if not show_ids:
|
||||
return {}
|
||||
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
|
||||
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
|
||||
|
||||
|
||||
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
|
||||
|
||||
@@ -164,6 +164,7 @@ def unified_library(
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
literal(False).label("resumable"),
|
||||
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
||||
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
||||
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
||||
@@ -211,6 +212,8 @@ def unified_library(
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
||||
(inp > 0).label("resumable"),
|
||||
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
||||
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
||||
agg.c.resumed_at.label("last_watched"),
|
||||
@@ -276,6 +279,7 @@ def unified_library(
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"season_count": r.season_count,
|
||||
"status": r.status,
|
||||
"resumable": r.resumable,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
|
||||
@@ -318,7 +318,7 @@ def collection_detail(
|
||||
if shows
|
||||
else []
|
||||
)
|
||||
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||
|
||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||
@@ -359,7 +359,7 @@ def collection_detail(
|
||||
items = [
|
||||
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
|
||||
if m.kind == "movie"
|
||||
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
|
||||
else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang)
|
||||
for m in members
|
||||
]
|
||||
years = [m.year for m in members if m.year is not None]
|
||||
|
||||
@@ -29,7 +29,7 @@ from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex.query import title_col
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import episode_card, movie_card, show_card
|
||||
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
@@ -279,7 +279,7 @@ def _relatedshow_cards(
|
||||
if sh is None or rk in seen:
|
||||
continue
|
||||
seen.add(rk)
|
||||
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
|
||||
out.append(show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang))
|
||||
if len(out) >= 20:
|
||||
break
|
||||
return out
|
||||
|
||||
@@ -23,7 +23,14 @@ 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 display_title, leaf_card, movie_card, show_card
|
||||
from app.plex.cards import (
|
||||
SHOW_NEW,
|
||||
ShowState,
|
||||
display_title,
|
||||
leaf_card,
|
||||
movie_card,
|
||||
show_card,
|
||||
)
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
@@ -69,7 +76,7 @@ def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> lis
|
||||
|
||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
|
||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||
return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows]
|
||||
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
@@ -232,7 +239,9 @@ def _continue_watching(
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
select(PlexShow, prog.c.resumed_at, prog.c.inprog).join(
|
||||
prog, prog.c.show_id == PlexShow.id
|
||||
),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
@@ -243,8 +252,9 @@ def _continue_watching(
|
||||
.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", lang=lang)))
|
||||
for sh, resumed_at, inprog in srows:
|
||||
state = ShowState("in_progress", (inprog or 0) > 0)
|
||||
rows.append((resumed_at, show_card(sh, state, lang=lang)))
|
||||
|
||||
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
@@ -420,6 +430,43 @@ def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
|
||||
return [*fresh, *rest.all()]
|
||||
|
||||
|
||||
def _kept(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
|
||||
) -> list[dict]:
|
||||
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
|
||||
stays in it, marked watched, and one gone from the scope's libraries drops out."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexItem.rating_key.in_(keep),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
cards = {
|
||||
c["id"]: c
|
||||
for c in [
|
||||
*_movie_cards(db, uid, list(movies), lang),
|
||||
*_show_cards(db, uid, list(shows), lang),
|
||||
]
|
||||
}
|
||||
return [cards[k] for k in keep if k in cards]
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
def surprise(
|
||||
scope: str = "both",
|
||||
@@ -427,6 +474,9 @@ def surprise(
|
||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||
# The previous draw's rating keys: a reshuffle deals other titles first.
|
||||
exclude: list[str] = Query(default=[], max_length=40),
|
||||
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
|
||||
# toggle) while the draw itself stands until the ↻.
|
||||
keep: list[str] = Query(default=[], max_length=40),
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -439,6 +489,8 @@ def surprise(
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
if keep:
|
||||
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import (
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
from app.plex.cards import movie_card, norm_lang, show_card
|
||||
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
|
||||
from app.plex.query import content_libs, split_csv, title_col
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
@@ -57,7 +57,7 @@ def _resolve_known_for(
|
||||
# A credit resolves against its OWN media_type's bucket only.
|
||||
if k.get("media_type") == "tv":
|
||||
if g in shows:
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, SHOW_NEW), lang=lang))
|
||||
elif g in movies:
|
||||
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
||||
return out[:_KNOWN_FOR_SHOWN]
|
||||
@@ -245,7 +245,7 @@ def person_detail(
|
||||
.all()
|
||||
)
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
|
||||
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
|
||||
|
||||
known_for = _resolve_known_for(
|
||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
||||
|
||||
@@ -331,6 +331,20 @@ def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep):
|
||||
assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"}
|
||||
|
||||
|
||||
def test_a_kept_draw_is_read_again_not_redrawn(db, monkeypatch, override_dep):
|
||||
# The rail's draw stands until the ↻, but its cards' watch state moves: `keep` answers the titles on
|
||||
# screen, in their order and as they stand now, a watched one included; the scope still bounds it.
|
||||
user = _seed(db)
|
||||
url = "/api/plex/surprise?scope=both&keep=m3&keep=sh1&keep=m1&keep=nope"
|
||||
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
|
||||
assert _ids(items) == ["m3", "sh1", "m1"]
|
||||
by_id = {c["id"]: c for c in items}
|
||||
assert by_id["m1"]["status"] == "watched"
|
||||
assert by_id["m3"]["position_seconds"] > 0
|
||||
movie_only = "/api/plex/surprise?scope=movie&keep=sh1&keep=m2"
|
||||
assert _ids(_get(db, user, monkeypatch, override_dep, movie_only).json()["items"]) == ["m2"]
|
||||
|
||||
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import rollup, sync
|
||||
from app.plex.cards import ShowState
|
||||
|
||||
|
||||
def _dt(day: int) -> datetime:
|
||||
@@ -184,21 +185,50 @@ class TestRollup:
|
||||
def test_state_map_new_inprogress_watched(self, db):
|
||||
s = _seed(db)
|
||||
show, user = s["show"], s["user"]
|
||||
# e1 in-progress, e2 new -> in_progress
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
||||
# e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
|
||||
# empty input short-circuits
|
||||
assert rollup.state_map(db, user.id, []) == {}
|
||||
# a fresh user with no state -> new
|
||||
other = User(email="rollup-other@example.com")
|
||||
db.add(other)
|
||||
db.commit()
|
||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
||||
# mark every episode watched for `user` -> watched
|
||||
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
|
||||
# e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart
|
||||
# would both play e2 from its start
|
||||
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
||||
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
||||
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
||||
e1, e2 = db.execute(
|
||||
select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number)
|
||||
).scalars()
|
||||
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
|
||||
db.commit()
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {
|
||||
show.id: ShowState("in_progress", False)
|
||||
}
|
||||
# mark every episode watched for `user` -> watched
|
||||
db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched"))
|
||||
db.commit()
|
||||
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)}
|
||||
|
||||
def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume(
|
||||
self, db, monkeypatch, override_dep
|
||||
):
|
||||
s = _seed(db)
|
||||
user = s["user"]
|
||||
cards = {
|
||||
i["id"]: i
|
||||
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||
}
|
||||
assert cards["s1"]["resumable"] is True
|
||||
db.execute(
|
||||
delete(PlexState).where(PlexState.user_id == user.id, PlexState.position_seconds > 0)
|
||||
)
|
||||
db.commit()
|
||||
cards = {
|
||||
i["id"]: i
|
||||
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
|
||||
}
|
||||
assert cards["s1"]["resumable"] is False
|
||||
|
||||
|
||||
def _fake_paginate(by_type):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
|
||||
// episode and opens the player on it; a started show's Restart opens it from the start; its title
|
||||
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./PlexPlayer", () => ({
|
||||
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
|
||||
<div>
|
||||
player {itemId} {fromStart ? "from the start" : "resumed"}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./plexViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexViews")>()),
|
||||
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const show: PlexCard = {
|
||||
id: "sh",
|
||||
type: "show",
|
||||
title: "Grimm",
|
||||
thumb: "",
|
||||
status: "in_progress",
|
||||
resumable: true,
|
||||
};
|
||||
|
||||
function mount() {
|
||||
vi.spyOn(api, "plexLibrary").mockResolvedValue({
|
||||
items: [show],
|
||||
total: 1,
|
||||
offset: 0,
|
||||
episodes: [],
|
||||
} as never);
|
||||
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
|
||||
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexBrowse />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { next };
|
||||
}
|
||||
|
||||
describe("PlexBrowse — a show's card in the grid", () => {
|
||||
it("plays the episode the server names", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
|
||||
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
|
||||
expect(next).toHaveBeenCalledWith("sh", "en");
|
||||
});
|
||||
|
||||
it("starts that episode over from its Restart", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
|
||||
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the show's page from its title, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the show's page from its ⓘ, asking nothing", async () => {
|
||||
const { next } = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
|
||||
expect(await screen.findByText("show page sh")).toBeTruthy();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -332,6 +332,7 @@ export default function PlexBrowse() {
|
||||
const resolveShow = usePlayShow(
|
||||
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
|
||||
(id) => sub.open({ kind: "show", id }),
|
||||
sub.view,
|
||||
);
|
||||
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
|
||||
function playShow(card: PlexCard, fromStart: boolean) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
|
||||
}));
|
||||
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import type { PlexCard } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-surprise",
|
||||
"plex-discover",
|
||||
"surprise",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
@@ -219,14 +222,24 @@ describe("PlexDiscover", () => {
|
||||
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
|
||||
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
|
||||
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
|
||||
expect(plexSurprise.mock.calls[0]?.[4]).toEqual([]);
|
||||
expect(plexSurprise.mock.calls[1]?.[4]).toEqual(["sp1"]);
|
||||
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
|
||||
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
|
||||
});
|
||||
|
||||
// Back from a card's player or page remounts the surface: the draw it left must still be there.
|
||||
it("keeps its draw across a remount and a return to the window; only ↻ redraws", async () => {
|
||||
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
|
||||
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
|
||||
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
let status: PlexCard["status"] = "new";
|
||||
plexSurprise.mockImplementation(
|
||||
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
|
||||
Promise.resolve({
|
||||
scope: "both",
|
||||
items: o.keep?.length
|
||||
? [{ ...card("sp1", "Random Pick"), status }]
|
||||
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
|
||||
}),
|
||||
);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const ui = (
|
||||
<QueryClientProvider client={qc}>
|
||||
@@ -241,17 +254,24 @@ describe("PlexDiscover", () => {
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const first = render(ui);
|
||||
await screen.findByText("Random Pick");
|
||||
await screen.findByText("Pick");
|
||||
first.unmount();
|
||||
render(ui);
|
||||
await screen.findByText("Random Pick");
|
||||
act(() => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
|
||||
status = "watched";
|
||||
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
|
||||
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
|
||||
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
|
||||
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
|
||||
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
|
||||
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
|
||||
expect(entry?.options.gcTime).toBe(Infinity);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
|
||||
await screen.findByText("Pick");
|
||||
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
|
||||
exclude: ["sp1"],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(plexSurprise).toHaveBeenCalledTimes(1);
|
||||
focusManager.setFocused(undefined);
|
||||
});
|
||||
|
||||
it("shows the empty message when every rail is empty", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
@@ -120,25 +120,36 @@ export default function PlexDiscover({
|
||||
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||
});
|
||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||
// A redraw hands over the draw on screen, so a small filtered pool deals its other titles first.
|
||||
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
|
||||
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
|
||||
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
|
||||
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
|
||||
const surpriseKey = qk.plexSurprise(scope, lang, filters);
|
||||
const redraw = useRef(false);
|
||||
const surpriseQ = useQuery({
|
||||
queryKey: surpriseKey,
|
||||
queryFn: () => {
|
||||
const shown = qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? [];
|
||||
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
|
||||
(c) => c.id,
|
||||
);
|
||||
const deal = redraw.current || shown.length === 0;
|
||||
redraw.current = false;
|
||||
return api.plexSurprise(
|
||||
scope,
|
||||
undefined,
|
||||
filters,
|
||||
lang,
|
||||
shown.map((c) => c.id),
|
||||
deal ? { exclude: shown } : { keep: shown },
|
||||
);
|
||||
},
|
||||
// A draw stands until ↻ (or another scope, language or filter) asks for a new one. A stale one was
|
||||
// redrawn on every remount — Back from the player or a show page — and on a return to the window,
|
||||
// so the card just looked at was gone from the rail it came from.
|
||||
staleTime: Infinity,
|
||||
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
|
||||
// a collected draw is a new one.
|
||||
gcTime: Infinity,
|
||||
});
|
||||
const reshuffle = () => {
|
||||
redraw.current = true;
|
||||
void surpriseQ.refetch();
|
||||
};
|
||||
|
||||
// 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.
|
||||
@@ -243,7 +254,7 @@ export default function PlexDiscover({
|
||||
cards={surprise}
|
||||
action={
|
||||
<IconButton
|
||||
onClick={() => surpriseQ.refetch()}
|
||||
onClick={reshuffle}
|
||||
loading={surpriseQ.isFetching}
|
||||
label={t("plex.discover.reshuffle")}
|
||||
>
|
||||
|
||||
@@ -179,11 +179,18 @@ describe("a Plex title's menu", () => {
|
||||
});
|
||||
|
||||
it("a started show continues, and starts its episode over from the next row", () => {
|
||||
setup(card({ type: "show", status: "in_progress" }));
|
||||
setup(card({ type: "show", status: "in_progress", resumable: true }));
|
||||
rightClick();
|
||||
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
|
||||
});
|
||||
|
||||
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
|
||||
setup(card({ type: "show", status: "in_progress", resumable: false }));
|
||||
rightClick();
|
||||
expect(labels()[0]).toBe("plex.play");
|
||||
expect(labels()).not.toContain("card.restartTitle");
|
||||
});
|
||||
|
||||
it("the card's '…' opens the same menu, and says so", () => {
|
||||
setup(card({}));
|
||||
const hoverMore = moreButtons()[0]!;
|
||||
|
||||
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
it("closing the player refreshes every surface that shows watch state, the Discover rails too", async () => {
|
||||
const { unmount, qc } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const refreshed = vi.spyOn(qc, "invalidateQueries");
|
||||
unmount();
|
||||
const keys = refreshed.mock.calls.map(([f]) => f?.queryKey);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining([qk.plexLibrary(), qk.plexShow(), qk.plexDiscover()]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("the subtitle poll and notice", () => {
|
||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||
const { status } = mount({
|
||||
|
||||
@@ -200,7 +200,7 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...show, status: "in_progress" }}
|
||||
card={{ ...show, status: "in_progress", resumable: true }}
|
||||
onOpen={onOpen}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
@@ -211,6 +211,19 @@ describe("PlexPosterCard with a next episode", () => {
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
|
||||
// Its next episode is untouched: Continue and Restart would both play it from its start.
|
||||
render(
|
||||
<PlexPosterCard
|
||||
card={{ ...show, status: "in_progress", resumable: false }}
|
||||
onOpen={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
|
||||
});
|
||||
|
||||
it("names the episode it would play once its preview is up, and not asked before", async () => {
|
||||
vi.useFakeTimers();
|
||||
const ask = vi
|
||||
|
||||
@@ -347,8 +347,7 @@ export function PlexPosterCard({
|
||||
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
|
||||
// The next episode by name: its numbers, or its title when Plex has none for it.
|
||||
const nextName = ep && episodeName(ep);
|
||||
// A show without its episode in hand continues where a started one stands (its rollup status).
|
||||
const started = ep ? plexStarted(ep) : isShow ? card.status === "in_progress" : plexStarted(card);
|
||||
const started = plexStarted(ep ?? card);
|
||||
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
|
||||
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
|
||||
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
|
||||
|
||||
@@ -30,12 +30,23 @@ function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const play = vi.fn();
|
||||
const openShow = vi.fn();
|
||||
const { result } = renderHook(() => usePlayShow(play, openShow), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
return { qc, play, openShow, playShow: result.current };
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
|
||||
{
|
||||
initialProps: { here: "grid" as unknown },
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
return { qc, play, openShow, playShow: result.current, rerender, unmount };
|
||||
}
|
||||
|
||||
/** Answers the test hands out one by one, in the order it chooses. */
|
||||
function answers() {
|
||||
const pending: ((v: { next: PlexCard | null }) => void)[] = [];
|
||||
vi.spyOn(api, "plexShowNext").mockImplementation(() => new Promise((r) => void pending.push(r)));
|
||||
return pending;
|
||||
}
|
||||
|
||||
describe("usePlayShow", () => {
|
||||
@@ -65,6 +76,32 @@ describe("usePlayShow", () => {
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acts on the last click only: an earlier, slower answer is dropped", async () => {
|
||||
const pending = answers();
|
||||
const { play, playShow } = mount();
|
||||
const first = playShow({ ...show, id: "a" }, false);
|
||||
const second = playShow({ ...show, id: "b" }, false);
|
||||
await act(async () => pending[1]!({ next: ep("b1") }));
|
||||
await act(async () => pending[0]!({ next: ep("a1") }));
|
||||
await act(() => Promise.all([first, second]));
|
||||
expect(play.mock.calls).toEqual([["b1", false]]);
|
||||
});
|
||||
|
||||
it("drops an answer that lands after the viewer has moved on, or left", async () => {
|
||||
const pending = answers();
|
||||
const { play, openShow, playShow, rerender, unmount } = mount();
|
||||
const moved = playShow(show, false);
|
||||
rerender({ here: "info" });
|
||||
await act(async () => pending[0]!({ next: ep("e1") }));
|
||||
await act(() => moved);
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
const left = playShow(show, false);
|
||||
unmount();
|
||||
await act(async () => pending[1]!({ next: null }));
|
||||
await act(() => left);
|
||||
expect(openShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when the question fails, and opens nothing", async () => {
|
||||
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
||||
const { play, openShow, playShow } = mount();
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useLatest } from "../lib/useLatest";
|
||||
|
||||
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
|
||||
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
|
||||
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
|
||||
* (`openShow`) and says so; a failed question is a toast, and nothing opens. */
|
||||
* (`openShow`) and says so; a failed question is a toast, and nothing opens.
|
||||
*
|
||||
* The answer acts only for the LAST click, and only where it was made (`here`, the caller's view): a
|
||||
* slow answer to a click the viewer has since moved on from (another card, Back, another module)
|
||||
* would otherwise pull them into a player they no longer asked for. */
|
||||
export function usePlayShow(
|
||||
play: (episodeId: string, fromStart: boolean) => void,
|
||||
openShow: (showId: string) => void,
|
||||
here: unknown,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const at = useLatest(here);
|
||||
const asked = useRef(0);
|
||||
// Unmounted, nothing of it may navigate any more.
|
||||
useEffect(() => () => void ++asked.current, []);
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
// `undefined` when the question failed (said in the toast).
|
||||
@@ -29,8 +40,10 @@ export function usePlayShow(
|
||||
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
|
||||
);
|
||||
return async (show: PlexCard, fromStart: boolean) => {
|
||||
const ask = ++asked.current;
|
||||
const from = at.current;
|
||||
const ep = await next(show.id);
|
||||
if (ep === undefined) return;
|
||||
if (ask !== asked.current || at.current !== from || ep === undefined) return;
|
||||
if (ep) play(ep.id, fromStart);
|
||||
else openShow(show.id);
|
||||
};
|
||||
|
||||
@@ -39,9 +39,13 @@ export const plexAddLabelKey = (c: PlexCard): string =>
|
||||
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
|
||||
|
||||
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
|
||||
* stored at 0, so it never counts. */
|
||||
* stored at 0, so it never counts. A show has one when an episode does (`resumable`, the episode its
|
||||
* click plays): part watched with its next episode untouched, Continue and Restart would be the same
|
||||
* thing, so it is not "started" here — its in-progress badge says how far it has got. */
|
||||
export const plexStarted = (c: PlexCard): boolean =>
|
||||
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
|
||||
isPlexPlayable(c)
|
||||
? c.status !== "watched" && (c.position_seconds ?? 0) > 0
|
||||
: c.resumable === true;
|
||||
|
||||
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
|
||||
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
|
||||
@@ -78,8 +82,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
||||
const playable = isPlexPlayable(c);
|
||||
const isShow = c.type === "show";
|
||||
const watched = c.status === "watched";
|
||||
// A show continues where its rollup says it stands, as its card's Continue + Restart do.
|
||||
const started = isShow ? c.status === "in_progress" : plexStarted(c);
|
||||
const started = plexStarted(c);
|
||||
return [
|
||||
...(play
|
||||
? [
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface PlexCard {
|
||||
status?: string; // new | watched | hidden
|
||||
position_seconds?: number;
|
||||
season_count?: number | null; // show
|
||||
// show: an unwatched episode holds a resume point (Continue resumes it, Restart replays it)
|
||||
resumable?: boolean;
|
||||
season_number?: number | null; // episode
|
||||
episode_number?: number | null; // episode
|
||||
show_title?: string | null; // episode (in a mixed playlist)
|
||||
@@ -626,18 +628,20 @@ export const plexApi = {
|
||||
if (filters) appendPlexFilters(u, filters);
|
||||
return req(`/api/plex/discover?${u.toString()}`);
|
||||
},
|
||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
|
||||
// the active filters like the other rails. `exclude` is the previous draw: dealt last, not first.
|
||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻), narrowed by the
|
||||
// active filters like the other rails. `exclude` is the previous draw: dealt last, not first. `keep`
|
||||
// asks for no draw at all: those titles again, in that order, with their watch state as it is now.
|
||||
plexSurprise: (
|
||||
scope: string,
|
||||
limit?: number,
|
||||
filters?: PlexFilters,
|
||||
lang?: string,
|
||||
exclude: string[] = [],
|
||||
{ exclude = [], keep = [] }: { exclude?: string[]; keep?: string[] } = {},
|
||||
): Promise<{ scope: string; items: PlexCard[] }> => {
|
||||
const u = new URLSearchParams({ scope });
|
||||
if (limit) u.set("limit", String(limit));
|
||||
for (const id of exclude) u.append("exclude", id);
|
||||
for (const id of keep) u.append("keep", id);
|
||||
if (lang === "hu") u.set("lang", "hu");
|
||||
if (filters) appendPlexFilters(u, filters);
|
||||
return req(`/api/plex/surprise?${u.toString()}`);
|
||||
|
||||
@@ -334,6 +334,8 @@ export function usePlexProgress(opts: {
|
||||
finishRef.current = () => {};
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||
// The Discover rails (Continue watching, the Surprise draw's cards) hold watch state too.
|
||||
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
||||
};
|
||||
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
|
||||
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
|
||||
|
||||
@@ -89,12 +89,13 @@ describe("qk — variadic composite plex keys", () => {
|
||||
FILTERS,
|
||||
]);
|
||||
expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([
|
||||
"plex-surprise",
|
||||
"plex-discover",
|
||||
"surprise",
|
||||
"movie",
|
||||
"hu",
|
||||
FILTERS,
|
||||
]);
|
||||
// A bare call stays a prefix for the reshuffle/watched-toggle invalidations.
|
||||
expect(qk.plexSurprise()).toEqual(["plex-surprise"]);
|
||||
// Under the discover root: whatever refreshes the rails refreshes the draw's cards too.
|
||||
expect(qk.plexSurprise()).toEqual(["plex-discover", "surprise"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,10 +123,11 @@ export const qk = {
|
||||
// refetches it alone.
|
||||
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
|
||||
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre
|
||||
// filter narrows against the language-specific genre column, like the grid). The ↻ reshuffle is the
|
||||
// query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the
|
||||
// card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer.
|
||||
plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest],
|
||||
// filter narrows against the language-specific genre column, like the grid). UNDER the discover root,
|
||||
// so whatever refreshes the rails (a watched toggle, a player closing) refreshes this one too: its
|
||||
// refetch reads the same draw again (`keep`), and only the ↻ deals a new one. As a sibling root the
|
||||
// draw's cards kept the watch state they were drawn with.
|
||||
plexSurprise: (...rest: unknown[]) => ["plex-discover", "surprise", ...rest],
|
||||
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
|
||||
// Optional trailing `lang`: 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