All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
104 lines
5.0 KiB
Python
104 lines
5.0 KiB
Python
"""The Home surface's rails: cross-source lists, assembled from `queries.py` and emitted as unified
|
|
cards.
|
|
|
|
The whole point of this module is that a rail is ONE list, not a YouTube list beside a Plex list. Two
|
|
side-by-side rails would just move the choice the user could not make ("which half do I look at?") one
|
|
level up, which is the opposite of what this epic is for.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import sysconfig
|
|
from app.discover import cards, queries
|
|
from app.discover.queries import RAIL_LIMIT
|
|
from app.models import PlexShow, User
|
|
from app.plex.lookups import by_rating_key
|
|
|
|
# Sort sentinel for a row whose timestamp is missing. A plain `(ts is not None, ts)` key looks like it
|
|
# handles NULLs and does not: two NULL rows compare equal on the flag and then compare `None < None`,
|
|
# which raises. A rail must not be able to 500 the landing page over missing metadata.
|
|
_NO_TS = datetime.min.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def plex_available(db: Session) -> bool:
|
|
"""Plex is a deployment-level toggle, not a per-user one (see `routes/me.py`). With it off, the
|
|
Home rails are YouTube-only and say nothing about Plex."""
|
|
return sysconfig.get_bool(db, "plex_enabled")
|
|
|
|
|
|
def playable(db: Session, uid: int, card: dict) -> dict | None:
|
|
"""The thing that would actually START from this card — itself for anything with a runtime, and
|
|
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
|
|
|
|
ONE function, because three surfaces need the same answer and a second implementation is how the
|
|
shelf and the rail came to disagree in the first place: the hero calls it to decide what it is
|
|
offering, `continue_watching` calls it to fill each show tile's `next`, and the comfort shelf
|
|
resolves the same way in `pins`. None when the show has no episodes mirrored yet.
|
|
|
|
A card that already carries a resolved `next` (a rail row) is taken at its word rather than
|
|
re-queried — the hero reads the rail it was handed."""
|
|
if not (card.get("source") == "plex" and card.get("kind") == "show"):
|
|
return card
|
|
if "next" in card:
|
|
return card["next"]
|
|
sh = by_rating_key(db, PlexShow, card["id"])
|
|
if sh is None:
|
|
return None
|
|
ep = queries.next_episode(db, uid, sh.id)
|
|
return cards.from_plex(ep) if ep else None
|
|
|
|
|
|
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]:
|
|
"""One resume rail across both sources, most recently progressed first.
|
|
|
|
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
|
|
user recognises — but it carries `next`: the episode that would actually start. Without it a
|
|
click on a half-watched series landed on the show page with six seasons to choose between, which
|
|
is the decision this whole surface exists to remove (the comfort shelf and the hero already
|
|
resolved; only this rail did not). The client plays `next ?? card`, so the tile stays a poster and
|
|
the click stays one click."""
|
|
rows: list[tuple[object, dict]] = [
|
|
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
|
|
]
|
|
if plex_available(db):
|
|
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
|
|
rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True)
|
|
out = [card for _, card in rows[:limit]]
|
|
# Resolve AFTER the slice: only the rows that survive it are worth a query.
|
|
for card in out:
|
|
if card["kind"] == "show":
|
|
card["next"] = playable(db, user.id, card)
|
|
return out
|
|
|
|
|
|
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]:
|
|
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
|
|
|
|
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
|
|
movie/show split — so a short pool never comes back all-YouTube or all-Plex just because one side
|
|
happened to be queried first.
|
|
|
|
WHICH source leads is decided by the seed, not by a hardcoded order. The hero draws a pool of ONE,
|
|
so a fixed order would mean the Plex library could never answer "egy este" while a single YouTube
|
|
video also fit — the reroll would look broken and the cross-source promise would quietly be a
|
|
YouTube promise. Parity on the same seed that orders the rows keeps it deterministic: the same
|
|
seed always gives the same answer, and pressing "Mást kérek" moves both halves together."""
|
|
halves = [
|
|
(cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)),
|
|
(
|
|
cards.from_plex,
|
|
queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [],
|
|
),
|
|
]
|
|
if seed % 2:
|
|
halves.reverse()
|
|
|
|
out: list[dict] = []
|
|
for i in range(max(len(rows) for _, rows in halves)):
|
|
for to_card, rows in halves:
|
|
if i < len(rows):
|
|
out.append(to_card(rows[i]))
|
|
return out[:limit]
|