diff --git a/backend/app/discover/cards.py b/backend/app/discover/cards.py index 9b4f9da1..336e917f 100644 --- a/backend/app/discover/cards.py +++ b/backend/app/discover/cards.py @@ -59,6 +59,13 @@ def _base( } +def key(card: dict) -> str: + """The identity of a card ACROSS sources — `:`. A Plex rating_key and a YouTube video + id could in principle collide as bare strings, and the reroll's exclusion list has to be able to + say "not that one" about either.""" + return f"{card['source']}:{card['id']}" + + def from_video(row: dict) -> dict: """`row` is a `feedquery.serialize` dict — the same object the feed grid renders.""" card = _base( diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py index b9b7b086..c124c45c 100644 --- a/backend/app/discover/hero.py +++ b/backend/app/discover/hero.py @@ -29,6 +29,10 @@ from app.plex.lookups import by_rating_key HERO_TIERS = ("resume", "comfort", "surprise") +# How deep the surprise tier draws before giving up. The client's exclusion window is 8 (see +# `Home.tsx`), so a draw of 12 can always step past a full window of already-shown picks. +SURPRISE_DRAW = 12 + def _playable(db: Session, uid: int, card: dict) -> dict | None: """A hero has to have a Play button, so a Plex SHOW is resolved to the episode that would start. @@ -50,22 +54,37 @@ def pick( *, resume: list[dict] | None = None, shelf: list[dict] | None = None, + exclude: set[str] | None = None, ) -> dict | None: """`{"tier": ..., "card": ...}`, or None when nothing at all fits. `resume` and `shelf` may be passed pre-computed so the consolidated `/api/discover` response does not build the same two rails twice; the reroll endpoint passes neither and pays only for what it - reaches.""" + reaches. + + `exclude` is what makes "Mást kérek" mean something. A new seed alone only re-draws the SURPRISE + tier: the resume tier deterministically returns the most recently progressed thing that fits, so + pressing reroll on a user who has something half-finished handed back the identical card and the + button looked dead (measured in the browser, 2026-09-06). Skipping the cards already shown makes + every press produce a different answer, at every tier.""" + skip = exclude or set() for card in rails.continue_watching(db, user) if resume is None else resume: playable = _playable(db, user.id, card) + # Excluded on the card the USER saw (the show), not only on what it resolves to, so a + # rerolled-past show does not come straight back as its own next episode. + if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip): + continue if playable is not None and fits(playable["remaining_seconds"], budget): return {"tier": "resume", "card": playable} for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf): - if fits(card["remaining_seconds"], budget): + if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): return {"tier": "comfort", "card": card} - for card in rails.surprise(db, user, budget, seed, limit=1): - return {"tier": "surprise", "card": card} + # A wider draw than the one card we need: the shuffle is seeded, so without the headroom an + # excluded pick would leave the tier empty rather than move past it. + for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW): + if cards.key(card) not in skip: + return {"tier": "surprise", "card": card} return None diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index 8ba888f2..b9cf4e92 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -60,18 +60,32 @@ def get_discover( } +# How many already-shown cards a reroll may carry. The client keeps a rolling window (see +# `Home.tsx`); the cap is here so a crafted URL cannot turn the exclusion list into an unbounded scan. +MAX_EXCLUDE = 32 + + @router.get("/pick") def get_pick( budget: Budget = DEFAULT_BUDGET, seed: int = 0, + exclude: str = "", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The "Mást kérek" reroll: a hero on its own, for a new seed and/or a new budget. One seed drives BOTH sources (see `discover/queries.py`) — otherwise a reroll would re-draw the - Plex half while the YouTube half stood still, and the button would look broken half the time.""" - return {"budget": budget, "hero": hero_mod.pick(db, user, budget, seed)} + Plex half while the YouTube half stood still, and the button would look broken half the time. + + `exclude` is a comma-separated list of `:` keys the user has already been shown. It + is what makes the button honest: a seed alone only moves the surprise tier, so a user with + something half-finished got the same card back every press.""" + skip = {p for p in exclude.split(",") if p} + return { + "budget": budget, + "hero": hero_mod.pick(db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE])), + } @router.get("/pins") diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py index 318f5301..1b51d191 100644 --- a/backend/tests/test_discover.py +++ b/backend/tests/test_discover.py @@ -356,6 +356,62 @@ def test_the_seed_decides_which_source_leads(db): assert leaders == {"youtube", "plex"} +def test_the_reroll_must_be_able_to_move_past_the_resume_tier(db): + """A new seed alone only re-draws the SURPRISE tier — the resume tier deterministically returns + the most recently progressed thing that fits, so without an exclusion the reroll handed the same + card straight back and the button looked dead (found in the browser, 2026-09-06).""" + user, _ = _seed_yt(db) + db.add_all( + [ + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW, + ), + VideoState( + user_id=user.id, + video_id="vshort", + status="new", + position_seconds=60, + progress_updated_at=NOW - timedelta(hours=1), + ), + ] + ) + db.commit() + + first = hero.pick(db, user, "any", seed=1) + assert first["card"]["id"] == "vmed" + # Same seed, same everything — only the exclusion differs. + second = hero.pick(db, user, "any", seed=1, exclude={"youtube:vmed"}) + assert second["card"]["id"] == "vshort" + + +def test_excluding_a_show_also_excludes_the_episode_it_resolves_to(db): + """The user rerolled past the SHOW they were shown; handing back its next episode under a + different title would be the same suggestion wearing a hat.""" + user, _ = _seed_yt(db) + _, show, ep1 = _seed_plex(db, user) + _enable_plex(db) + db.add( + PlexState( + user_id=user.id, + item_id=ep1.id, + status="new", + position_seconds=120, + progress_updated_at=NOW, + ) + ) + db.commit() + + shown = hero.pick(db, user, "any", seed=1) + assert shown["card"]["id"] == "ps1e1" # the show resolved to its in-progress episode + # Excluding what the USER saw — the show — must move the tier on, not return the episode again. + after = hero.pick(db, user, "any", seed=1, exclude={"plex:ps1"}) + assert after is None or after["card"]["id"] != "ps1e1" + + # ── comfort shelf ───────────────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/Home.dom.test.tsx b/frontend/src/components/Home.dom.test.tsx new file mode 100644 index 00000000..1b2e5e1d --- /dev/null +++ b/frontend/src/components/Home.dom.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +// +// E24 S2: the Home surface. Covers the four behaviours the browser round actually caught, each of +// which is invisible to tsc and to a render-only check: +// • the hero's kicker comes from the SERVER's tier, never from the card's shape; +// • the hero is not printed twice (once as the hero, once as the first resume tile); +// • a budget change asks for the new budget and keeps the old answer on screen meanwhile; +// • the reroll sends the already-shown card as an exclusion — without it the resume tier hands the +// identical card straight back and the button looks dead. +// i18n is key-passthrough; the api is mocked so queries resolve synchronously. The rails live inside +// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.time ? `${key}:${opts.time}` : key, + i18n: { language: "en" }, + }), + }; +}); + +const discover = vi.fn(); +const discoverPick = vi.fn(); +vi.mock("../lib/api", () => ({ + api: { + discover: (...a: unknown[]) => discover(...a), + discoverPick: (...a: unknown[]) => discoverPick(...a), + setState: () => Promise.resolve(), + watchLaterAdd: () => Promise.resolve(), + watchLaterRemove: () => Promise.resolve(), + }, + discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`, +})); + +// The page hands Plex playback off to the Plex module; neither provider is under test here. +vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) })); +vi.mock("./PlexProvider", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) })); + +import Home from "./Home"; + +function ytCard(id: string, title: string, extra: Record = {}) { + return { + source: "youtube", + kind: "video", + id, + title, + subtitle: "A channel", + thumb: null, + aspect: "wide", + duration_seconds: 600, + position_seconds: 120, + remaining_seconds: 480, + status: "new", + plex: null, + video: { + id, + title, + channel_id: "UC1", + channel_title: "A channel", + channel_thumbnail: null, + channel_url: "https://youtube.com/channel/UC1", + published_at: "2026-01-01T00:00:00Z", + thumbnail_url: null, + duration_seconds: 600, + view_count: 10, + is_short: false, + live_status: "none", + status: "new", + position_seconds: 120, + saved: false, + watch_url: `https://www.youtube.com/watch?v=${id}`, + }, + ...extra, + }; +} + +const payload = (heroId: string, tier = "resume") => ({ + budget: "any", + hero: { tier, card: ytCard(heroId, `Hero ${heroId}`) }, + continue_watching: [ytCard(heroId, `Hero ${heroId}`), ytCard("v2", "Second thing")], + comfort: [], +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + discover.mockReset(); + discoverPick.mockReset(); + discover.mockResolvedValue(payload("v1")); +}); +afterEach(cleanup); + +describe("Home", () => { + it("labels the hero from the server's tier, not from the card", async () => { + // The same card shape is served under a `surprise` tier: a client that inferred "where you left + // off" from the resume position would get this wrong, which is the dishonest state we forbid. + discover.mockResolvedValue(payload("v1", "surprise")); + mount(); + expect(await screen.findByText("home.hero.surprise")).toBeTruthy(); + expect(screen.queryByText(/home\.hero\.resume/)).toBeNull(); + }); + + it("interpolates the remaining time into the resume kicker", async () => { + mount(); + // 480s left → 8:00, passed through the key-passthrough t() as `: