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:
2026-10-09 22:01:28 +02:00
parent 5572499b56
commit 6df257d7df
26 changed files with 433 additions and 80 deletions
+3 -3
View File
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.discover import cards, queries from app.discover import cards, queries
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
from app.plex import rollup 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 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. # 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) sh = db.get(PlexShow, pin.plex_show_id)
if sh is None: if sh is None:
return None return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
card = cards.from_plex(show_card(sh, status, lang=lang)) card = cards.from_plex(show_card(sh, state, lang=lang))
ep = queries.next_episode(db, user.id, sh.id, lang=lang) ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None nxt = cards.from_plex(ep) if ep else None
+7 -4
View File
@@ -40,7 +40,7 @@ from app.models import (
VideoState, VideoState,
) )
from app.plex import rollup 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 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 # 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: if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter( 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.total,
prog.c.watched, prog.c.watched,
prog.c.inprog, prog.c.inprog,
"in_progress", "in_progress",
).where(PlexShow.library_id.in_(show_libs)) ).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) sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all(): ).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 return out
+18 -2
View File
@@ -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 lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
router. Pure functions — no DB, no request state.""" router. Pure functions — no DB, no request state."""
from typing import NamedTuple
from app.models import PlexItem, PlexShow, PlexState 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 # 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 # 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 # `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 { return {
"id": sh.rating_key, "id": sh.rating_key,
"type": "show", "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), "art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count, "season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status, "status": state.status,
"resumable": state.resumable,
} }
+8 -2
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState 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 # 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. # 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" 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: def rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the """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 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: if not show_ids:
return {} return {}
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all() 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]: def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
+4
View File
@@ -164,6 +164,7 @@ def unified_library(
cast(null(), Integer).label("season_count"), cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"), func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"), m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked # "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). # 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"), 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"), PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"), literal(0).label("position_seconds"),
s_status.label("status"), 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 # 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. # rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"), agg.c.resumed_at.label("last_watched"),
@@ -276,6 +279,7 @@ def unified_library(
"thumb": f"/api/plex/image/{r.rk}", "thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count, "season_count": r.season_count,
"status": r.status, "status": r.status,
"resumable": r.resumable,
} }
) )
+3 -3
View File
@@ -24,7 +24,7 @@ from app.models import (
) )
from app.plex import rollup from app.plex import rollup
from app.plex import sync as plex_sync 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.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
@@ -318,7 +318,7 @@ def collection_detail(
if shows if shows
else [] 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} sresumed = {r.show_id: r.resumed_at for r in show_rows}
def _movie_last_watched(st: PlexState | None) -> datetime | None: def _movie_last_watched(st: PlexState | None) -> datetime | None:
@@ -359,7 +359,7 @@ def collection_detail(
items = [ items = [
movie_card(m.obj, mstate.get(m.obj.id), lang=lang) movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
if m.kind == "movie" 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 for m in members
] ]
years = [m.year for m in members if m.year is not None] years = [m.year for m in members if m.year is not None]
+2 -2
View File
@@ -29,7 +29,7 @@ from app.plex import people as plex_people
from app.plex import rollup, subtitles from app.plex import rollup, subtitles
from app.plex.query import title_col from app.plex.query import title_col
from app.plex import tmdb as plex_tmdb 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.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404 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: if sh is None or rk in seen:
continue continue
seen.add(rk) 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: if len(out) >= 20:
break break
return out return out
+57 -5
View File
@@ -23,7 +23,14 @@ from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup 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 ( from app.plex.query import (
LibraryFilters, LibraryFilters,
apply_meta_filters, 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]: 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]) 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( 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. # `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() prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter( 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.total,
prog.c.watched, prog.c.watched,
prog.c.inprog, prog.c.inprog,
@@ -243,8 +252,9 @@ def _continue_watching(
.order_by(prog.c.resumed_at.desc().nullslast()) .order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT) .limit(RAIL_LIMIT)
).all() ).all()
for sh, resumed_at in srows: for sh, resumed_at, inprog in srows:
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang))) 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) rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]] 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()] 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") @router.get("/surprise")
def surprise( def surprise(
scope: str = "both", scope: str = "both",
@@ -427,6 +474,9 @@ def surprise(
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40), limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
# The previous draw's rating keys: a reshuffle deals other titles first. # The previous draw's rating keys: a reshuffle deals other titles first.
exclude: list[str] = Query(default=[], max_length=40), 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(), f: LibraryFilters = Depends(),
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -439,6 +489,8 @@ def surprise(
lang = _norm_lang(lang) lang = _norm_lang(lang)
p = f.as_dict() p = f.as_dict()
movie_ids, show_ids = _scope_libs(db, scope) 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) per = min(limit, SURPRISE_PER_KIND)
movies = [] movies = []
if movie_ids: if movie_ids:
+3 -3
View File
@@ -19,7 +19,7 @@ from app.models import (
) )
from app.plex import rollup from app.plex import rollup
from app.plex import tmdb as plex_tmdb 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 from app.plex.query import content_libs, split_csv, title_col
router = APIRouter(prefix="/api/plex", tags=["plex"]) 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. # A credit resolves against its OWN media_type's bucket only.
if k.get("media_type") == "tv": if k.get("media_type") == "tv":
if g in shows: 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: elif g in movies:
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang)) out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
return out[:_KNOWN_FOR_SHOWN] return out[:_KNOWN_FOR_SHOWN]
@@ -245,7 +245,7 @@ def person_detail(
.all() .all()
) )
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) 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( known_for = _resolve_known_for(
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
+14
View File
@@ -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"} 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): def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json() d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
+37 -7
View File
@@ -18,6 +18,7 @@ from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import rollup, sync from app.plex import rollup, sync
from app.plex.cards import ShowState
def _dt(day: int) -> datetime: def _dt(day: int) -> datetime:
@@ -184,21 +185,50 @@ class TestRollup:
def test_state_map_new_inprogress_watched(self, db): def test_state_map_new_inprogress_watched(self, db):
s = _seed(db) s = _seed(db)
show, user = s["show"], s["user"] show, user = s["show"], s["user"]
# e1 in-progress, e2 new -> 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: "in_progress"} assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
# empty input short-circuits # empty input short-circuits
assert rollup.state_map(db, user.id, []) == {} assert rollup.state_map(db, user.id, []) == {}
# a fresh user with no state -> new # a fresh user with no state -> new
other = User(email="rollup-other@example.com") other = User(email="rollup-other@example.com")
db.add(other) db.add(other)
db.commit() db.commit()
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"} assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
# mark every episode watched for `user` -> watched # 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)) db.execute(delete(PlexState).where(PlexState.user_id == user.id))
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars(): e1, e2 = db.execute(
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched")) 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() 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): 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();
});
});
+1
View File
@@ -332,6 +332,7 @@ export default function PlexBrowse() {
const resolveShow = usePlayShow( const resolveShow = usePlayShow(
(id, fromStart) => sub.open({ kind: "player", id, fromStart }), (id, fromStart) => sub.open({ kind: "player", id, fromStart }),
(id) => sub.open({ kind: "show", id }), (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. // A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
function playShow(card: PlexCard, fromStart: boolean) { function playShow(card: PlexCard, fromStart: boolean) {
@@ -7,7 +7,7 @@
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. // synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; 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(() => { beforeAll(() => {
class Stub { class Stub {
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
})); }));
import PlexDiscover from "./PlexDiscover"; import PlexDiscover from "./PlexDiscover";
import { qk } from "../lib/queryKeys";
import type { PlexCard } from "../lib/api";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
expect.objectContaining({ genres: [] }), expect.objectContaining({ genres: [] }),
]); ]);
expect(keys).toContainEqual([ expect(keys).toContainEqual([
"plex-surprise", "plex-discover",
"surprise",
"both", "both",
"en", "en",
expect.objectContaining({ genres: [] }), expect.objectContaining({ genres: [] }),
@@ -219,14 +222,24 @@ describe("PlexDiscover", () => {
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2)); await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched 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. // 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[0]?.[4]).toEqual({ exclude: [] });
expect(plexSurprise.mock.calls[1]?.[4]).toEqual(["sp1"]); 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. // Back from a card's player or page remounts the surface: the draw it left must still be there, with
it("keeps its draw across a remount and a return to the window; only ↻ redraws", async () => { // 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); 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 qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const ui = ( const ui = (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
@@ -241,17 +254,24 @@ describe("PlexDiscover", () => {
</QueryClientProvider> </QueryClientProvider>
); );
const first = render(ui); const first = render(ui);
await screen.findByText("Random Pick"); await screen.findByText("Pick");
first.unmount(); first.unmount();
render(ui); render(ui);
await screen.findByText("Random Pick"); await screen.findByText("Random Pick");
act(() => { // Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
focusManager.setFocused(false); status = "watched";
focusManager.setFocused(true); 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 () => { it("shows the empty message when every rail is empty", async () => {
+20 -9
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react"; import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
@@ -120,25 +120,36 @@ export default function PlexDiscover({
queryFn: () => api.plexDiscover(scope, lang, filters), queryFn: () => api.plexDiscover(scope, lang, filters),
}); });
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface. // 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 surpriseKey = qk.plexSurprise(scope, lang, filters);
const redraw = useRef(false);
const surpriseQ = useQuery({ const surpriseQ = useQuery({
queryKey: surpriseKey, queryKey: surpriseKey,
queryFn: () => { 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( return api.plexSurprise(
scope, scope,
undefined, undefined,
filters, filters,
lang, 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 // Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
// redrawn on every remount — Back from the player or a show page — and on a return to the window, // a collected draw is a new one.
// so the card just looked at was gone from the rail it came from. gcTime: Infinity,
staleTime: 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 // 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. // 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} cards={surprise}
action={ action={
<IconButton <IconButton
onClick={() => surpriseQ.refetch()} onClick={reshuffle}
loading={surpriseQ.isFetching} loading={surpriseQ.isFetching}
label={t("plex.discover.reshuffle")} 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", () => { 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(); rightClick();
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); 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", () => { it("the card's '…' opens the same menu, and says so", () => {
setup(card({})); setup(card({}));
const hoverMore = moreButtons()[0]!; const hoverMore = moreButtons()[0]!;
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
expect(v.getAttribute("src")).toBeNull(); 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", () => { describe("the subtitle poll and notice", () => {
it("asks about where a resumed session was asked to start, before playback has ticked", async () => { it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
const { status } = mount({ const { status } = mount({
+14 -1
View File
@@ -200,7 +200,7 @@ describe("PlexPosterCard with a next episode", () => {
const onRestart = vi.fn(); const onRestart = vi.fn();
render( render(
<PlexPosterCard <PlexPosterCard
card={{ ...show, status: "in_progress" }} card={{ ...show, status: "in_progress", resumable: true }}
onOpen={onOpen} onOpen={onOpen}
onRestart={onRestart} onRestart={onRestart}
/>, />,
@@ -211,6 +211,19 @@ describe("PlexPosterCard with a next episode", () => {
expect(onOpen).toHaveBeenCalledTimes(1); 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 () => { it("names the episode it would play once its preview is up, and not asked before", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const ask = vi const ask = vi
+1 -2
View File
@@ -347,8 +347,7 @@ export function PlexPosterCard({
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined; const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// The next episode by name: its numbers, or its title when Plex has none for it. // The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep); const nextName = ep && episodeName(ep);
// A show without its episode in hand continues where a started one stands (its rollup status). const started = plexStarted(ep ?? card);
const started = ep ? plexStarted(ep) : isShow ? card.status === "in_progress" : plexStarted(card);
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined; 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 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). // 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 qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const play = vi.fn(); const play = vi.fn();
const openShow = vi.fn(); const openShow = vi.fn();
const { result } = renderHook(() => usePlayShow(play, openShow), { const { result, rerender, unmount } = renderHook(
wrapper: ({ children }: { children: ReactNode }) => ( ({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
<QueryClientProvider client={qc}>{children}</QueryClientProvider> {
), initialProps: { here: "grid" as unknown },
}); wrapper: ({ children }: { children: ReactNode }) => (
return { qc, play, openShow, playShow: result.current }; <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", () => { describe("usePlayShow", () => {
@@ -65,6 +76,32 @@ describe("usePlayShow", () => {
expect(play).not.toHaveBeenCalled(); 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 () => { it("says so when the question fails, and opens nothing", async () => {
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down")); vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
const { play, openShow, playShow } = mount(); const { play, openShow, playShow } = mount();
+15 -2
View File
@@ -1,19 +1,30 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, type PlexCard } from "../lib/api"; import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { plexLang } from "../i18n"; 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 /** 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 * 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 * 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( export function usePlayShow(
play: (episodeId: string, fromStart: boolean) => void, play: (episodeId: string, fromStart: boolean) => void,
openShow: (showId: string) => void, openShow: (showId: string) => void,
here: unknown,
) { ) {
const qc = useQueryClient(); 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 { t, i18n } = useTranslation();
const lang = plexLang(i18n.language); const lang = plexLang(i18n.language);
// `undefined` when the question failed (said in the toast). // `undefined` when the question failed (said in the toast).
@@ -29,8 +40,10 @@ export function usePlayShow(
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")), (e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
); );
return async (show: PlexCard, fromStart: boolean) => { return async (show: PlexCard, fromStart: boolean) => {
const ask = ++asked.current;
const from = at.current;
const ep = await next(show.id); 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); if (ep) play(ep.id, fromStart);
else openShow(show.id); else openShow(show.id);
}; };
@@ -39,9 +39,13 @@ export const plexAddLabelKey = (c: PlexCard): string =>
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show"; 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 /** 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 => 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 /** `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 * 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 playable = isPlexPlayable(c);
const isShow = c.type === "show"; const isShow = c.type === "show";
const watched = c.status === "watched"; const watched = c.status === "watched";
// A show continues where its rollup says it stands, as its card's Continue + Restart do. const started = plexStarted(c);
const started = isShow ? c.status === "in_progress" : plexStarted(c);
return [ return [
...(play ...(play
? [ ? [
+7 -3
View File
@@ -49,6 +49,8 @@ export interface PlexCard {
status?: string; // new | watched | hidden status?: string; // new | watched | hidden
position_seconds?: number; position_seconds?: number;
season_count?: number | null; // show 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 season_number?: number | null; // episode
episode_number?: number | null; // episode episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist) show_title?: string | null; // episode (in a mixed playlist)
@@ -626,18 +628,20 @@ export const plexApi = {
if (filters) appendPlexFilters(u, filters); if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/discover?${u.toString()}`); 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 // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻), narrowed by the
// the active filters like the other rails. `exclude` is the previous draw: dealt last, not first. // 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: ( plexSurprise: (
scope: string, scope: string,
limit?: number, limit?: number,
filters?: PlexFilters, filters?: PlexFilters,
lang?: string, lang?: string,
exclude: string[] = [], { exclude = [], keep = [] }: { exclude?: string[]; keep?: string[] } = {},
): Promise<{ scope: string; items: PlexCard[] }> => { ): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope }); const u = new URLSearchParams({ scope });
if (limit) u.set("limit", String(limit)); if (limit) u.set("limit", String(limit));
for (const id of exclude) u.append("exclude", id); 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 (lang === "hu") u.set("lang", "hu");
if (filters) appendPlexFilters(u, filters); if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/surprise?${u.toString()}`); return req(`/api/plex/surprise?${u.toString()}`);
+2
View File
@@ -334,6 +334,8 @@ export function usePlexProgress(opts: {
finishRef.current = () => {}; finishRef.current = () => {};
qc.invalidateQueries({ queryKey: qk.plexLibrary() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() }); 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 // 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. // changing when the effect re-runs (id/qc only), matching the original in-component effect.
+4 -3
View File
@@ -89,12 +89,13 @@ describe("qk — variadic composite plex keys", () => {
FILTERS, FILTERS,
]); ]);
expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([ expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([
"plex-surprise", "plex-discover",
"surprise",
"movie", "movie",
"hu", "hu",
FILTERS, FILTERS,
]); ]);
// A bare call stays a prefix for the reshuffle/watched-toggle invalidations. // Under the discover root: whatever refreshes the rails refreshes the draw's cards too.
expect(qk.plexSurprise()).toEqual(["plex-surprise"]); expect(qk.plexSurprise()).toEqual(["plex-discover", "surprise"]);
}); });
}); });
+5 -4
View File
@@ -123,10 +123,11 @@ export const qk = {
// refetches it alone. // refetches it alone.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre // 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 // filter narrows against the language-specific genre column, like the grid). UNDER the discover root,
// query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the // so whatever refreshes the rails (a watched toggle, a player closing) refreshes this one too: its
// card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer. // refetch reads the same draw again (`keep`), and only the ↻ deals a new one. As a sibling root the
plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest], // draw's cards kept the watch state they were drawn with.
plexSurprise: (...rest: unknown[]) => ["plex-discover", "surprise", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
// Optional trailing `lang`: a language switch remounts the detail with a new key so HU/EN // 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. // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.