diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py
index df7a3320..e75a2e88 100644
--- a/backend/app/discover/hero.py
+++ b/backend/app/discover/hero.py
@@ -81,7 +81,11 @@ def pick(
return None
def _comfort() -> dict | None:
- for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf):
+ if shelf is None:
+ own = pins.shelf(db, user, include_plex=rails.plex_available(db))
+ else:
+ own = shelf
+ for card in pins.playables(own):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return card
return None
diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py
index 98a6a0f9..c3c88a35 100644
--- a/backend/app/discover/pins.py
+++ b/backend/app/discover/pins.py
@@ -75,6 +75,31 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
+def reorder(db: Session, user: User, ids: list[int]) -> None:
+ """Put the listed pins in the listed order, WITHIN the slots they already hold.
+
+ The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home
+ rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the
+ listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden
+ ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
+ are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
+ ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
+ current = list_pins(db, user)
+ owned = {p.id for p in current}
+ listed: list[int] = []
+ for i in ids:
+ if i in owned and i not in listed:
+ listed.append(i)
+ chosen = set(listed)
+ queue = iter(listed)
+ order = [next(queue) if p.id in chosen else p.id for p in current]
+ by_id = {p.id: p for p in current}
+ for position, pin_id in enumerate(order):
+ if by_id[pin_id].position != position:
+ by_id[pin_id].position = position
+ db.flush()
+
+
def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list(
db.execute(
@@ -188,10 +213,21 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch) -> dict
return card
-def shelf(db: Session, user: User) -> list[dict]:
- """The whole shelf, in the user's own order."""
+_PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
+
+
+def shelf(db: Session, user: User, *, include_plex: bool = True) -> list[dict]:
+ """The whole shelf, in the user's own order.
+
+ `include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
+ 2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that
+ lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf
+ (`GET /pins`), and switching Plex back on brings the tiles back in their old slots
+ (`reorder` keeps a hidden pin's place)."""
out = []
pins = list_pins(db, user)
+ if not include_plex:
+ pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
batch = _Batch.load(db, user, pins)
for pin in pins:
card = _shelf_card(db, user, pin, batch)
@@ -202,5 +238,17 @@ def shelf(db: Session, user: User) -> list[dict]:
def playables(shelf_cards: list[dict]) -> list[dict]:
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
- with nothing unwatched left, or an empty playlist, simply is not an answer right now."""
- return [c["next"] for c in shelf_cards if c.get("next")]
+ with nothing unwatched left, or an empty playlist, simply is not an answer right now.
+
+ Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself
+ pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a
+ kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's
+ pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin")."""
+ return [
+ {
+ **c["next"],
+ "pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")},
+ }
+ for c in shelf_cards
+ if c.get("next")
+ ]
diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py
index 1ac1bea1..a1f7ae23 100644
--- a/backend/app/routes/discover.py
+++ b/backend/app/routes/discover.py
@@ -13,7 +13,7 @@ from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
-from sqlalchemy import select, update
+from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -53,7 +53,9 @@ def get_discover(
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load."""
resume = rails.continue_watching(db, user)
- shelf = pins.shelf(db, user)
+ # Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
+ # beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
+ shelf = pins.shelf(db, user, include_plex=rails.plex_available(db))
return {
"budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf),
@@ -180,18 +182,11 @@ def reorder_pins(
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
- """Rewrite the shelf order from a full list of pin ids.
+ """Rewrite the shelf order from the list of pin ids the client shows.
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
- tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not
- this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin
- deleted meanwhile should reorder what still exists, not fail wholesale."""
- owned = {p.id for p in pins.list_pins(db, user)}
- for position, pin_id in enumerate(i for i in body.ids if i in owned):
- db.execute(
- update(ComfortPin)
- .where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
- .values(position=position)
- )
+ tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
+ PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
+ pins.reorder(db, user, body.ids)
db.commit()
return {"ok": True}
diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py
index a25beb5c..c8c3bc47 100644
--- a/backend/tests/test_discover.py
+++ b/backend/tests/test_discover.py
@@ -1007,3 +1007,65 @@ def test_deleting_a_pin_reports_what_went(db, as_user):
r = c.delete(f"/api/discover/pins/{pin_id}")
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
assert c.get("/api/discover/pins").json()["items"] == []
+
+
+def test_with_plex_off_the_home_shelf_hides_plex_pins_but_keeps_them(db, as_user):
+ """The owner's call (2026-09-19): with the deployment's Plex toggle off, the Home shelf goes
+ YouTube-only like the rails beside it — a Plex tile there offered a Play that landed on a gated
+ module. The hero's comfort tier draws from the same filtered shelf, and the pin menus' own
+ listing still sees every pin, so nothing the user curated is lost."""
+ user, _ = _seed_yt(db)
+ _seed_plex(db, user)
+ db.add_all(
+ [
+ ComfortPin(
+ user_id=user.id,
+ plex_item_id=db.scalar(select(PlexItem.id).where(PlexItem.rating_key == "pm1")),
+ position=0,
+ ),
+ ComfortPin(user_id=user.id, video_id="vlong", position=1),
+ ]
+ )
+ db.commit()
+ c = as_user(user)
+ assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["vlong"]
+ assert c.get("/api/discover/pick?start=1").json()["hero"]["card"]["id"] == "vlong"
+ assert {t["id"] for t in c.get("/api/discover/pins").json()["items"]} == {"pm1", "vlong"}
+ _enable_plex(db)
+ assert [t["id"] for t in c.get("/api/discover").json()["comfort"]] == ["pm1", "vlong"]
+
+
+def test_a_reorder_of_the_visible_pins_keeps_the_hidden_ones_in_place(db, as_user):
+ """The client sends the order it SHOWS. With Plex off that leaves the Plex pins out, and
+ numbering only the listed ids 0..n-1 put two pins on one position and moved the hidden one about
+ when Plex came back. The listed pins trade the slots they hold; the rest stay put."""
+ user, ch = _seed_yt(db)
+ _seed_plex(db, user)
+ c = as_user(user)
+ _enable_plex(db)
+ a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
+ hidden = c.post("/api/discover/pins", json={"kind": "plex_item", "ref": "pm1"}).json()["id"]
+ b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
+ assert c.post("/api/discover/pins/reorder", json={"ids": [b, b, a]}).status_code == 200
+ assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, hidden, a]
+ positions = db.scalars(select(ComfortPin.position).where(ComfortPin.user_id == user.id)).all()
+ assert sorted(positions) == [0, 1, 2]
+
+
+def test_a_hero_from_a_show_pin_names_the_pin_it_came_from(db, as_user):
+ """A show's next EPISODE is not itself pinned, so the hero's menu used to look the episode up,
+ miss, and offer "Pin" under "From your shelf" — creating a second pin. The card now carries
+ the pin it was resolved from, which the menu acts on (owner's call: "Unpin")."""
+ user, _ = _seed_yt(db)
+ _seed_plex(db, user)
+ _enable_plex(db)
+ c = as_user(user)
+ assert c.post("/api/discover/pins", json={"kind": "plex_show", "ref": "ps1"}).status_code == 200
+ got = c.get("/api/discover/pick?start=1").json()["hero"]
+ assert got["tier"] == "comfort" and got["card"]["id"] == "ps1e1"
+ assert got["card"]["pinned_from"] == {
+ "source": "plex",
+ "kind": "show",
+ "id": "ps1",
+ "title": "A Show",
+ }
diff --git a/frontend/src/components/AppCommandPalette.tsx b/frontend/src/components/AppCommandPalette.tsx
index 132aa69b..2951848e 100644
--- a/frontend/src/components/AppCommandPalette.tsx
+++ b/frontend/src/components/AppCommandPalette.tsx
@@ -135,10 +135,11 @@ export default function AppCommandPalette() {
label: t("palette.actions.surprise"),
icon: ,
keywords: t("home.title"),
- run: () => {
- setPage("home");
- requestSurprise();
- },
+ // The token is minted on the entry the push made (see lib/homeIntent), and asks for the
+ // SURPRISE tier: the command is labelled "Meglepetés", and the reroll's plain next press
+ // is answered by whichever tier the rotation reaches — for anyone with a shelf, "A
+ // polcodról" under a command that promised a surprise (owner's call, 2026-09-19).
+ run: () => setPage("home", requestSurprise),
},
{
id: "action:shortcuts",
diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx
index c41abdb4..3778bcdd 100644
--- a/frontend/src/components/Home.dom.test.tsx
+++ b/frontend/src/components/Home.dom.test.tsx
@@ -52,6 +52,7 @@ vi.mock("../lib/api", () => ({
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
+ HERO_TIERS: ["resume", "comfort", "surprise"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
@@ -609,8 +610,38 @@ describe("Home", () => {
);
expect(await screen.findByText("Rerolled")).toBeTruthy();
expect(discoverPick).toHaveBeenCalledTimes(1);
- // …and on the press count the server reads as "which tier answers first".
- expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 1);
+ // …and on the press count the server reads as "which tier answers first": 2 leads with the
+ // SURPRISE tier, which is what a command labelled "Meglepetés" promised. A plain next press (1)
+ // led with the shelf, so anyone with one got "A polcodról" instead (owner's call, 2026-09-19).
+ expect(discoverPick).toHaveBeenCalledWith("any", expect.any(Number), [], 2);
+ });
+
+ // The rotation carries on from the requested tier: the page's own button after a palette surprise
+ // is the NEXT tier (resume), not a second surprise and not a jump back to press 1.
+ it("lets the button's next press carry the rotation on from the requested tier", async () => {
+ discoverPick.mockResolvedValue({
+ budget: "any",
+ hero: { tier: "surprise", card: ytCard("v9", "Rerolled") },
+ });
+ requestSurprise();
+ mount();
+ expect(await screen.findByText("Rerolled")).toBeTruthy();
+ fireEvent.click(screen.getByText("home.hero.reroll"));
+ await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
+ expect(discoverPick.mock.lastCall?.[3]).toBe(3);
+ });
+
+ // A token minted for a navigation that never mounted Home (a failed lazy chunk, a Back before
+ // the module arrived) must not replay on the NEXT visit: it names the history entry it was
+ // requested for, and Home mounting on any other entry drops it.
+ it("drops a surprise request that was made for another history entry", async () => {
+ requestSurprise();
+ window.history.pushState(null, "");
+ mount();
+ await screen.findByText("Hero v1");
+ expect(discoverPick).not.toHaveBeenCalled();
+ // …and it is spent, not merely skipped: the entry it named does not revive it either.
+ expect(takeSurprise()).toBe(false);
});
it("says the shelf is empty rather than hiding it", async () => {
diff --git a/frontend/src/components/Home.pin.dom.test.tsx b/frontend/src/components/Home.pin.dom.test.tsx
index 3fe11a54..857e86b4 100644
--- a/frontend/src/components/Home.pin.dom.test.tsx
+++ b/frontend/src/components/Home.pin.dom.test.tsx
@@ -61,6 +61,7 @@ vi.mock("../lib/api", () => ({
},
HttpError: class extends Error {},
DISCOVER_BUDGETS: ["short", "medium", "long", "any"],
+ HERO_TIERS: ["resume", "comfort", "surprise"],
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
@@ -302,6 +303,34 @@ describe("Home — the comfort shelf's context menu", () => {
await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(11));
});
+ // A comfort hero resolved from a pinned SHOW is that show's next episode, which is not itself
+ // pinned: the menu looked the episode up, missed, and offered "Pin" under "From your shelf" —
+ // taking it created a second pin. The card now names the pin it came from, and the row acts on
+ // THAT (owner's call, 2026-09-19: "Unpin").
+ it("unpins the SHOW a comfort hero's episode was resolved from", async () => {
+ const show = {
+ ...base("ps1", "Pinned show"),
+ source: "plex",
+ kind: "show",
+ video: null,
+ pin_id: 21,
+ };
+ const episode = {
+ ...base("ps1e2", "Next episode"),
+ source: "plex",
+ kind: "episode",
+ video: null,
+ pinned_from: { source: "plex", kind: "show", id: "ps1", title: "Pinned show" },
+ };
+ discover.mockResolvedValue({ ...payload(), hero: { tier: "comfort", card: episode } });
+ comfortPins.mockResolvedValue({ items: [...shelf, show] });
+ mount();
+ fireEvent.contextMenu(await screen.findByText("Next episode"));
+ fireEvent.click(await waitFor(() => item("home.pin.remove")));
+ await waitFor(() => expect(removeComfortPin).toHaveBeenCalledWith(21));
+ expect(addComfortPin).not.toHaveBeenCalled();
+ });
+
it("says so rather than guessing while the shelf is still loading", async () => {
// "Not pinned" and "not known yet" must not render as the same row: the pin label is only
// trustworthy once the shelf has answered.
diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx
index 370a266e..d9d63fdc 100644
--- a/frontend/src/components/Home.tsx
+++ b/frontend/src/components/Home.tsx
@@ -18,6 +18,7 @@ import {
type DiscoverBudget,
type DiscoverCard,
type DiscoverPayload,
+ HERO_TIERS,
type HeroTier,
type PlexCard,
type Video,
@@ -350,7 +351,14 @@ function Hero({
- c.title} />
+ {/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
+ pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
+ The owner's call (2026-09-19): the row unpins the show. */}
+ pinTargetForCard(c.pinned_from ?? c)}
+ title={(c) => (c.pinned_from ?? c).title}
+ />
);
}
@@ -654,28 +662,40 @@ export default function Home() {
[budget, qc, t],
);
- const reroll = useCallback(() => {
- // Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
- // never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
- // would look like a dead button).
- if (heroKey)
- setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
- setPress((n) => n + 1);
- setSeed((prev) => {
- let next = prev;
- while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
- return next;
- });
- }, [heroKey]);
+ const reroll = useCallback(
+ (lead?: HeroTier) => {
+ // Remember what is on screen so the next pick cannot be the same card, then take a fresh seed —
+ // never 0 (which means "the hero the page loaded with") and never the current one (a cache hit
+ // would look like a dead button).
+ if (heroKey)
+ setSeen((prev) => [...prev.filter((k) => k !== heroKey), heroKey].slice(-SEEN_WINDOW));
+ // `lead` asks for a tier: the next press whose rotation leads with it. The rotation carries on
+ // from there, so the button's following press is the tier after it, as it would have been.
+ setPress((n) => {
+ let next = n + 1;
+ // Guarded on membership: a click handler passed straight in would hand over its event
+ // as `lead`, and this loop would never end.
+ if (lead && HERO_TIERS.includes(lead))
+ while (HERO_TIERS[next % HERO_TIERS.length] !== lead) next += 1;
+ return next;
+ });
+ setSeed((prev) => {
+ let next = prev;
+ while (next === prev || next === 0) next = Math.floor(Math.random() * 1_000_000) + 1;
+ return next;
+ });
+ },
+ [heroKey],
+ );
// "Meglepetés" from the command palette: the request is made on another page, so it waits as a
// token until this module's chunk has actually mounted (see lib/homeIntent). The subscribed value
// is what WAKES this effect; `takeSurprise()` is what decides — it clears the token and answers
// false to every later pass, including StrictMode's second mount invocation and the re-runs
- // `reroll`'s changing identity causes.
+ // `reroll`'s changing identity causes — and to a token minted for another history entry.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
- if (surpriseRequest && takeSurprise()) reroll();
+ if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
}, [surpriseRequest, reroll]);
if (q.isPending) return ;
@@ -716,7 +736,7 @@ export default function Home() {
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
- onReroll={reroll}
+ onReroll={() => reroll()}
rerolling={rerolling}
/>
) : (
@@ -743,7 +763,7 @@ export default function Home() {
type="button"
icon={}
loading={rerolling}
- onClick={reroll}
+ onClick={() => reroll()}
>
{t("home.hero.reroll")}
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index fe29cf5d..dc33daad 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -156,6 +156,23 @@ describe("NavigationProvider · never under an open overlay", () => {
expect(window.history.state).toMatchObject({ sfPage: "plex" });
});
+ // `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so
+ // over an open player it must wait for the push, and with nothing to push it still runs.
+ it("runs setPage's `then` after the push, on the entry the push made", async () => {
+ const { result } = render();
+ await openOverlay();
+ const seen: unknown[] = [];
+ expect(result.current.page).not.toBe("plex");
+ act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage)));
+ expect(seen).toEqual([]);
+ await settle();
+ await settle();
+ expect(seen).toEqual(["plex"]);
+
+ act(() => result.current.setPage("plex", () => seen.push("again")));
+ expect(seen).toEqual(["plex", "again"]);
+ });
+
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
it("setPage to the current page closes the overlay and pushes nothing", async () => {
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index b196bcce..7a37a724 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -57,13 +57,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
channelViewRef.current = channelView;
const setPage = useCallback(
- (next: Page) =>
+ (next: Page, then?: () => void) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
// actually happens, which over an open player is a moment after this call.
closeOverlaysThenNavigate(() => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
- if (next === pageRef.current && !channelViewRef.current) return;
+ if (next === pageRef.current && !channelViewRef.current) {
+ then?.();
+ return;
+ }
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
@@ -95,6 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}),
);
notifyNav();
+ then?.();
}),
[],
);
diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/components/comfortPin.tsx
index 888b22b1..d7ff1bc7 100644
--- a/frontend/src/components/comfortPin.tsx
+++ b/frontend/src/components/comfortPin.tsx
@@ -49,7 +49,9 @@ export const pinPlex = (card: { id: string; type: string }): PinTarget => ({
/** A unified discover card back to the thing it was pinned as — the inverse of the mapping in
* `app/discover/cards.py`. Null for a kind that cannot be pinned. */
-export function pinTargetForCard(card: DiscoverCard): PinTarget | null {
+export function pinTargetForCard(
+ card: Pick,
+): PinTarget | null {
if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind });
if (card.kind === "video") return pinVideo(card.id);
if (card.kind === "channel") return pinChannel(card.id);
diff --git a/frontend/src/components/navigationContext.ts b/frontend/src/components/navigationContext.ts
index b557cefc..5c3b1684 100644
--- a/frontend/src/components/navigationContext.ts
+++ b/frontend/src/components/navigationContext.ts
@@ -23,8 +23,12 @@ export interface NavigationActions {
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* after the call; with nothing open it is synchronous. The requested page being already current
- * (with no channel page over it) pushes nothing — but the overlays still close. */
- setPage: (next: Page) => void;
+ * (with no channel page over it) pushes nothing — but the overlays still close.
+ *
+ * `then` runs once the navigation has happened (the push made, or found unnecessary) — for a
+ * request that belongs to the NEW entry, which over an open player does not exist yet at the
+ * call (`lib/homeIntent`). */
+ setPage: (next: Page, then?: () => void) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 44756f22..4f45b1df 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -28,7 +28,7 @@ import { feedApi } from "./api/feed";
export type * from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
-export { DISCOVER_BUDGETS, discoverCardKey } from "./api/discover";
+export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
export type * from "./api/tagModes";
export {
DEFAULT_TAG_MODES,
diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts
index d3052278..e2dfab03 100644
--- a/frontend/src/lib/api/discover.ts
+++ b/frontend/src/lib/api/discover.ts
@@ -15,6 +15,9 @@ export type DiscoverBudget = (typeof DISCOVER_BUDGETS)[number];
// Where the hero came from. Travels with the card so the page can label it honestly instead of
// inferring "where you left off" over something the user has never opened.
export type HeroTier = "resume" | "comfort" | "surprise";
+/** The server's tier order (`app/discover/hero.py::HERO_TIERS`): a reroll's press count `start`
+ * leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */
+export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"];
export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
@@ -43,6 +46,10 @@ export interface DiscoverCard {
plex: PlexCard | null;
// Shelf entries only.
pin_id?: number;
+ /** A comfort-tier hero only: the shelf entry it was resolved from — for a pinned SHOW, the show
+ * whose next episode this is. The hero's pin row acts on it rather than on the episode, which is
+ * not itself pinned (`app/discover/pins.py::playables`). */
+ pinned_from?: Pick;
/** What actually plays when the tile is clicked: a show's next episode, a channel's newest
* unwatched video, or a copy of the tile itself when it plays directly. Null when the pin has
* nothing to offer right now (a fully-watched channel, an empty playlist). */
diff --git a/frontend/src/lib/homeIntent.ts b/frontend/src/lib/homeIntent.ts
index f58d173f..4b8e8c44 100644
--- a/frontend/src/lib/homeIntent.ts
+++ b/frontend/src/lib/homeIntent.ts
@@ -1,3 +1,4 @@
+import { ensureNavId, peekNavId } from "./history";
import { createStore } from "./store";
// A one-shot "…and surprise me straight away" request for the Home surface (E24), handed over by
@@ -5,18 +6,23 @@ import { createStore } from "./store";
//
// A TOKEN that waits, not an event that fires: Home is a lazy module and is normally NOT mounted
// when the request is made (the palette navigates to it in the same click), so an event would be
-// shouted into an empty room. Home takes the token on arrival, which clears it, so walking back to
-// Home an hour later does not replay the press — a stale reroll would look like the page deciding
-// on its own that the answer you came back for was wrong.
-const surprise = createStore(0);
+// shouted into an empty room. Home takes the token on arrival, which clears it.
+//
+// The token is the HISTORY ENTRY it was requested for, and Home honours it only on that entry. A
+// plain counter outlived a navigation that never mounted Home — a lazy chunk that failed, a Back
+// before the module arrived — and replayed on the next visit, an hour later: the page deciding on
+// its own that the answer you came back for was wrong. So it is minted AFTER the push
+// (`setPage`'s `then`), on the entry the push made; Home mounting on any other entry drops it.
+const surprise = createStore(null);
-/** Ask Home to reroll its hero as soon as it is on screen. Pair it with a `setPage("home")`. */
-export const requestSurprise = (): void => surprise.set((n) => n + 1);
+/** Ask Home to reroll its hero on THIS history entry. Call it once the navigation to Home has
+ * pushed — `setPage("home", requestSurprise)` — so the token names Home's own entry. */
+export const requestSurprise = (): void => surprise.set(ensureNavId());
-/** The pending token (0 = nothing pending) — the render subscription that wakes Home up. Home only. */
-export const useSurpriseRequest = (): number => surprise.use();
+/** The pending token (null = nothing pending) — the render subscription that wakes Home up. Home only. */
+export const useSurpriseRequest = (): number | null => surprise.use();
-/** Take the pending request, clearing it; false when there was nothing to take.
+/** Take the pending request, clearing it; true only when it was requested for the entry Home is on.
*
* Compare-and-clear rather than a plain read + `consume()`, because a reroll is NOT idempotent — it
* spends a `/discover/pick` and steps the tier rotation — and React 18's StrictMode invokes a mount
@@ -24,7 +30,8 @@ export const useSurpriseRequest = (): number => surprise.use();
* "Meglepetés" from another module arrived at Home as `start=2`, two picks deep, before the page had
* painted once. Checking the LIVE store is what makes the second pass a no-op. */
export const takeSurprise = (): boolean => {
- if (!surprise.get()) return false;
- surprise.set(0);
- return true;
+ const entry = surprise.get();
+ if (entry === null) return false;
+ surprise.set(null);
+ return entry === peekNavId();
};