From 013f19faca886bae6bff17f5c28d00b593290240 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 03:31:02 +0200 Subject: [PATCH 01/15] refactor(plex): move content_libs down into the query layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse scope is a pure DB query with no request state, but it lived in routes/plex/_shared.py. E24's Home surface needs the same scope from a domain package, and a domain package importing from routes/ inverts the one-way dependency CLAUDE.md draws. Moved to app/plex/query.py and renamed to content_libs (public — it is a domain helper now). Swept all five call sites and both tests in one go rather than leaving a re-export shim: the canonical-patterns table already names the routes.admin re-export as the anti-pattern. --- backend/app/plex/query.py | 26 +++++++++++++++++++++++++- backend/app/routes/plex/_shared.py | 24 +----------------------- backend/app/routes/plex/browse.py | 7 ++++--- backend/app/routes/plex/collections.py | 5 ++--- backend/app/routes/plex/people.py | 7 +++---- backend/tests/test_plex_overlay.py | 6 +++--- backend/tests/test_plex_person.py | 6 +++--- 7 files changed, 41 insertions(+), 40 deletions(-) diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 19f21885..c3fd6f7e 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -8,8 +8,9 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import and_, case, func, or_, select, type_coerce from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Session -from app.models import PlexItem +from app.models import PlexItem, PlexLibrary TS_CONFIG = "public.unaccent_simple" @@ -342,3 +343,26 @@ def apply_show_state_filter(query, tot, wat, inp, show: str): if show == "unwatched": return query.where(wat == 0, inp == 0) return query + + +def content_libs(db: Session) -> tuple[list[int], list[int]]: + """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single + source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never + browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5). + + Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's + Home surface needs the same scope, and a domain package importing from `routes/` would invert the + one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the + move.""" + libs = ( + db.execute( + select(PlexLibrary).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ) + .scalars() + .all() + ) + movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] + show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + return movie_lib_ids, show_lib_ids diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index 0e50040b..2a193eab 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -3,13 +3,9 @@ import logging from fastapi import HTTPException -from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import ( - PlexItem, - PlexLibrary, -) +from app.models import PlexItem from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") @@ -25,21 +21,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem: def _norm_lang(lang: str | None) -> str: """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" return "hu" if lang == "hu" else "en" - - -def _content_libs(db: Session) -> tuple[list[int], list[int]]: - """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single - source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never - browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).""" - libs = ( - db.execute( - select(PlexLibrary).where( - PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) - ) - ) - .scalars() - .all() - ) - movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] - show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] - return movie_lib_ids, show_lib_ids diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 0ff9dff8..5cdda59a 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -34,6 +34,7 @@ from app.plex.query import ( apply_meta_filters, apply_movie_state_filter, apply_show_state_filter, + content_libs, field_recheck, genres_col, meta_filter_conds, @@ -47,7 +48,7 @@ from app.plex.query import ( vector_col, vector_match, ) -from app.routes.plex._shared import _content_libs, _norm_lang +from app.routes.plex._shared import _norm_lang from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -109,7 +110,7 @@ def unified_library( offset = max(0, offset) lang = _norm_lang(lang) p = f.as_dict() - movie_lib_ids, show_lib_ids = _content_libs(db) + movie_lib_ids, show_lib_ids = content_libs(db) want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) @@ -328,7 +329,7 @@ def facets( "duration_min": None, "duration_max": None, } - all_movie_ids, all_show_ids = _content_libs(db) + all_movie_ids, all_show_ids = content_libs(db) movie_ids = all_movie_ids if scope in ("movie", "both") else [] show_ids = all_show_ids if scope in ("show", "both") else [] if not movie_ids and not show_ids: diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 308ccb68..862f5f9a 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -19,8 +19,7 @@ from app.plex import sync as plex_sync from app.plex.cards import movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key -from app.plex.query import like_escape, runtime_by_key, top_art_keys -from app.routes.plex._shared import _content_libs +from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys # The most-restrictive → least ordering used to pick a collection's effective content rating from its # members (Plex shows one cert on the collection; we don't store it, so derive it). @@ -128,7 +127,7 @@ def collections( # members (a collection lives in one library, so exactly one side has art) — a show collection would # otherwise render blank; the collection's own poster is the next fallback, then the colour hash. # Runtime is movie-only (shows have no single duration). - movie_ids, show_ids = _content_libs(db) + movie_ids, show_ids = content_libs(db) keys = [c.rating_key for c in rows] art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2) art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2) diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 9c1e3db8..ccef7c92 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -19,8 +19,7 @@ from app.models import ( from app.plex import rollup from app.plex import tmdb as plex_tmdb from app.plex.cards import movie_card, show_card -from app.plex.query import split_csv -from app.routes.plex._shared import _content_libs +from app.plex.query import content_libs, split_csv router = APIRouter(prefix="/api/plex", tags=["plex"]) log = logging.getLogger("siftlode.plex") @@ -71,7 +70,7 @@ def people_cards( wanted = split_csv(names)[:16] if not wanted: return {"people": []} - movie_lib_ids, show_lib_ids = _content_libs(db) + movie_lib_ids, show_lib_ids = content_libs(db) want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) photos = { @@ -185,7 +184,7 @@ def person_detail( and _should_schedule_enrich(person.id) ): background_tasks.add_task(_enrich_person_bg, person.id) - movie_lib_ids, show_lib_ids = _content_libs(db) + movie_lib_ids, show_lib_ids = content_libs(db) movies: list[dict] = [] if movie_lib_ids: diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index 3fa4c0c6..5b30d00b 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -12,7 +12,7 @@ import app.plex.sync as syncmod from app import sysconfig from app.models import PlexItem, PlexLibrary from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay -from app.routes.plex._shared import _content_libs +from app.plex.query import content_libs class _StubPlex: @@ -155,7 +155,7 @@ def test_sync_designates_overlay_and_writes_hu(db, monkeypatch): assert m.title_hu == "Öldöklés" assert m.genres_hu == ["Akció"] # The HU section is not browsable and mirrored no rows of its own. - movie_ids, _ = _content_libs(db) + movie_ids, _ = content_libs(db) assert en.id in movie_ids and hu.id not in movie_ids assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0 assert stats["overlaid"] == 1 @@ -240,7 +240,7 @@ def test_content_libs_excludes_overlay_libraries(db): db.add(overlay) db.commit() - movie_ids, show_ids = _content_libs(db) + movie_ids, show_ids = content_libs(db) assert en_lib.id in movie_ids assert overlay.id not in movie_ids # the HU overlay is never browsable assert show_ids == [en_shows.id] diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 5daa5809..760aac7c 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -1,6 +1,6 @@ """DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name, -each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs` +each carrying the caller's watch-state, plus the cached headshot. Also covers `content_libs` scoping (disabled libraries excluded) and the kind filter (episodes are not movies). Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. @@ -20,7 +20,7 @@ from app.models import ( PlexState, User, ) -from app.routes.plex._shared import _content_libs +from app.plex.query import content_libs NAME = "Robert De Niro" @@ -151,5 +151,5 @@ def test_content_libs_partitions_only_enabled(db): ] ) db.commit() - movie_ids, show_ids = _content_libs(db) + movie_ids, show_ids = content_libs(db) assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded From 5703c4d7136751eb85a8358b7a6a680478e7654c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 03:31:09 +0200 Subject: [PATCH 02/15] fix(plex): a null progress timestamp must sort a rail row last, not raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Continue-watching and Surprise rails sorted on `(ts is not None, ts)`. That key LOOKS null-safe and is not: two rows that both lack a timestamp compare equal on the flag and then compare `None < None`, which raises — so a show whose rollup has no `resumed_at`, or a movie state written before `progress_updated_at` existed, could 500 the rail instead of sorting last. Replaced with an explicit epoch sentinel, and named it beside the other rail constants so the sibling rail E24 adds cannot re-derive the same broken key. --- backend/app/routes/plex/discover.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 68c88937..5e8816a7 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -14,6 +14,8 @@ grid uses, applied to each rail's movie + show query (a rail may legitimately co the client hides). Genre-language matches the grid; duration is movie-only. """ +from datetime import datetime, timezone + from fastapi import APIRouter, Depends, Query from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session @@ -27,12 +29,13 @@ from app.plex.query import ( LibraryFilters, apply_meta_filters, apply_show_state_filter, + content_libs, genres_col, meta_filter_conds, top_art_keys, ) from app.plex.tmdb import resolve_recs -from app.routes.plex._shared import _content_libs, _norm_lang +from app.routes.plex._shared import _norm_lang router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -40,10 +43,16 @@ RAIL_LIMIT = 20 GENRE_TILES = 12 SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total) +# Sort sentinel for a row with no timestamp. The `(ts is not None, ts)` key this replaced LOOKS +# NULL-safe and is not: two null-timestamped rows compare equal on the flag and then compare +# `None < None`, which raises — so a show whose rollup has no `resumed_at`, or a movie state written +# before `progress_updated_at` existed, could 500 a rail rather than sort last (E24). +_NO_TS = datetime.min.replace(tzinfo=timezone.utc) + def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]: """The movie/show library ids visible for this scope (empty list = out of scope).""" - movie_ids, show_ids = _content_libs(db) + movie_ids, show_ids = content_libs(db) return ( movie_ids if scope in ("movie", "both") else [], show_ids if scope in ("show", "both") else [], @@ -170,7 +179,7 @@ def _recently_watched( ) for ep, st, sh_title, sh_rk in db.execute(eq).all(): rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk))) - rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True) + rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True) return [card for _, card in rows[:RAIL_LIMIT]] @@ -234,7 +243,7 @@ def _continue_watching( for sh, resumed_at in srows: rows.append((resumed_at, show_card(sh, "in_progress"))) - rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True) + rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True) return [card for _, card in rows[:RAIL_LIMIT]] From 53b50231ff15295f90a67c87759a2b8d75facf6c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 03:31:26 +0200 Subject: [PATCH 03/15] =?UTF-8?q?feat(e24):=20the=20Home=20surface=20read-?= =?UTF-8?q?model=20=E2=80=94=20hero,=20resume=20rail,=20comfort=20shelf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E24 S1. The anti-paralysis answer layer, served from our own Postgres with no upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's own comfort shelf. Survey first, because the roadmap line was wrong about the starting point: Plex already ships the whole trio (E5's Discover rails, including a reshuffling Surprise me), the YouTube side ships none of it, and nothing anywhere answers the question across both. The data already existed on both sides — VideoState and PlexState are structurally identical, the feed has an `in_progress` filter and a seeded shuffle, and the YT player already resumes position. - app/discover/budget.py — the time bands. "How much time do you have" (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire: deterministic, answerable from a column we have, and it picks the source for free. Matched against REMAINING time, so a film with 8 minutes left is a ten-minute answer. An unknown runtime fits only the unbounded band. - app/discover/cards.py — a thin unified card that carries the NATIVE feed / Plex payload, so the page renders with the existing card components instead of a third one. `aspect` is the one new field: a rail needs to know poster from 16:9, and that is a property of the artwork, not of the module. - app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a surface that has a sidebar. One seed drives both halves so a reroll moves them together. - app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in the payload so the client labels it honestly instead of guessing. - comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1 rather than a flag or a polymorphic string ref. A flag cannot order a shelf or hold a channel; a string ref would let a deleted library item leave a dead tile on the one surface whose whole job is to be reliable. Two honest-state rules the tests pin: the surprise draw excludes anything already started (the resume tier may have skipped it for not fitting, and offering it back as "Meglepetés" would contradict the question), and which source leads the draw follows the seed, so the Plex library can actually answer "egy este". Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770, pytest 987. --- backend/alembic/versions/0083_comfort_pins.py | 78 +++ backend/app/discover/__init__.py | 10 + backend/app/discover/budget.py | 62 ++ backend/app/discover/cards.py | 134 +++++ backend/app/discover/hero.py | 71 +++ backend/app/discover/pins.py | 154 +++++ backend/app/discover/queries.py | 309 ++++++++++ backend/app/discover/rails.py | 71 +++ backend/app/main.py | 2 + backend/app/models.py | 50 ++ backend/app/routes/discover.py | 165 ++++++ backend/tests/test_discover.py | 529 ++++++++++++++++++ 12 files changed, 1635 insertions(+) create mode 100644 backend/alembic/versions/0083_comfort_pins.py create mode 100644 backend/app/discover/__init__.py create mode 100644 backend/app/discover/budget.py create mode 100644 backend/app/discover/cards.py create mode 100644 backend/app/discover/hero.py create mode 100644 backend/app/discover/pins.py create mode 100644 backend/app/discover/queries.py create mode 100644 backend/app/discover/rails.py create mode 100644 backend/app/routes/discover.py create mode 100644 backend/tests/test_discover.py diff --git a/backend/alembic/versions/0083_comfort_pins.py b/backend/alembic/versions/0083_comfort_pins.py new file mode 100644 index 00000000..e4774457 --- /dev/null +++ b/backend/alembic/versions/0083_comfort_pins.py @@ -0,0 +1,78 @@ +"""comfort_pins — the shelf you fall back to when you cannot decide + +Revision ID: 0083_comfort_pins +Revises: 0082_invite_lang +Create Date: 2026-09-06 + +E24's one new table. The shelf holds five different things — a video, a YouTube channel, a Plex show, +a Plex movie, a playlist — because comfort media is usually "put on X", a series or a channel, and +almost never one particular video. + +Three shapes were possible and two are wrong here: + +- a boolean on `video_states` / `plex_states` cannot ORDER a shelf, and has nowhere to put a channel + or a show (neither has a per-user state row at all); +- a polymorphic `(kind, ref)` string pair orders and holds anything, but drops the foreign key — and a + Plex item removed from the library, or a channel the user unsubscribes and prunes, would then leave + a dead tile on the one surface whose entire job is to be reliable when nothing else is. + +So: one nullable CASCADE-ing FK per kind, plus `num_nonnulls(...) = 1`. The union invariant lives in +the schema rather than in a comment at every insert site, which is the same rule that the +`cancelled`/`canceled` bug taught this codebase. + +NULLs are DISTINCT in a Postgres unique index, so the five per-kind `(user_id, )` uniques each +stop a double-pin without colliding with the other four kinds' NULL rows. + +No backfill: an empty shelf is the honest state for every existing account. The surface renders its +own empty state and invites the first pin. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0083_comfort_pins" +down_revision: str | None = "0082_invite_lang" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "comfort_pins", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("video_id", sa.String(length=16), sa.ForeignKey("videos.id", ondelete="CASCADE")), + sa.Column( + "channel_id", sa.String(length=32), sa.ForeignKey("channels.id", ondelete="CASCADE") + ), + sa.Column("plex_item_id", sa.Integer(), sa.ForeignKey("plex_items.id", ondelete="CASCADE")), + sa.Column("plex_show_id", sa.Integer(), sa.ForeignKey("plex_shows.id", ondelete="CASCADE")), + sa.Column("playlist_id", sa.Integer(), sa.ForeignKey("playlists.id", ondelete="CASCADE")), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.CheckConstraint( + "num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1", + name="ck_comfort_pin_one_ref", + ), + sa.UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"), + sa.UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"), + sa.UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"), + sa.UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"), + sa.UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"), + ) + op.create_index("ix_comfort_pins_user_id", "comfort_pins", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_comfort_pins_user_id", table_name="comfort_pins") + op.drop_table("comfort_pins") diff --git a/backend/app/discover/__init__.py b/backend/app/discover/__init__.py new file mode 100644 index 00000000..e6f779b7 --- /dev/null +++ b/backend/app/discover/__init__.py @@ -0,0 +1,10 @@ +"""E24 — the read-model behind the Home surface ("Mit nézzek most?"). + +The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own +comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule +the Plex Discover surface follows). + +Layering, mirroring `app/plex/`: `budget` (the time bands) and `cards` (the unified row shape) are +pure; `queries` holds the SQL; `rails`, `pins` and `hero` compose. Nothing here imports from +`app.routes` — `routes/discover.py` is a thin HTTP shell over these. +""" diff --git a/backend/app/discover/budget.py b/backend/app/discover/budget.py new file mode 100644 index 00000000..23e2f558 --- /dev/null +++ b/backend/app/discover/budget.py @@ -0,0 +1,62 @@ +"""The time budget — E24's replacement for "what mood are you in?". + +`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's +later BA-interview note (#23) says the mood half is not what makes this work: the friction is +deciding at all. A mood→genre mapping would also be invented data — we store no mood signal and never +will — whereas "how much time do you have" is a question the library can actually answer, from a +column we already have. + +So the surface asks about TIME, and the answer is deterministic: four bands, no model, no latency. +The band also picks the source for free — ten minutes is a YouTube video, an evening is a movie — +which is exactly the cross-source behaviour the Home surface exists to provide. + +The bands overlap on purpose. "I have 30 minutes" should not be answered with a two-minute Short just +because it fits; a lower bound is as much a part of the promise as the upper one. +""" + +from typing import Literal + +Budget = Literal["short", "medium", "long", "any"] + +DEFAULT_BUDGET: Budget = "any" + +# budget -> (min_seconds, max_seconds); None = unbounded on that side. +BANDS: dict[str, tuple[int | None, int | None]] = { + "short": (None, 12 * 60), # "10 perc" — a bit of slack over the round number the chip shows + "medium": (8 * 60, 40 * 60), # "30 perc" + "long": (40 * 60, None), # "Egy este" + "any": (None, None), # "Mindegy" +} + + +def band(budget: str) -> tuple[int | None, int | None]: + """The (min, max) seconds for a budget. Unknown budgets fall back to the unbounded band rather + than raising: the route validates the vocabulary with a Literal, so anything reaching here with a + bad value is our own bug, and an unfiltered shelf is a better failure than a 500 on the landing + page.""" + return BANDS.get(budget, BANDS["any"]) + + +def remaining_seconds(duration_seconds: int | None, position_seconds: int) -> int | None: + """What is actually LEFT to watch. The question is "how much time do you have", so a 90-minute + film with 8 minutes to go belongs in the ten-minute band, not the evening one. Unknown duration + stays unknown — see `fits`.""" + if duration_seconds is None: + return None + return max(0, duration_seconds - max(0, position_seconds)) + + +def fits(seconds: int | None, budget: str) -> bool: + """Does a runtime fit the budget? + + An unknown duration fits only the unbounded band. We cannot promise an item finishes inside ten + minutes when we do not know how long it is, and quietly including it would make the one control + on this page dishonest — the failure mode the house rules call an un-honest state.""" + lo, hi = band(budget) + if seconds is None: + return lo is None and hi is None + if lo is not None and seconds < lo: + return False + if hi is not None and seconds > hi: + return False + return True diff --git a/backend/app/discover/cards.py b/backend/app/discover/cards.py new file mode 100644 index 00000000..9b4f9da1 --- /dev/null +++ b/backend/app/discover/cards.py @@ -0,0 +1,134 @@ +"""The unified discover card: one row shape the Home surface can render without knowing which +module it came from. + +This is deliberately a THIN mapping, not a new serializer layer. The per-source projections already +exist and are already the shapes the frontend's `VideoCard` and `PlexPosterCard` consume — +`feedquery.serialize` for YouTube, `plex.cards.movie_card`/`show_card`/`leaf_card` for Plex — so each +unified card carries the native payload under `video` / `plex` alongside the handful of fields the +rail chrome itself needs (title, thumbnail, progress, runtime). The Home page therefore renders with +the existing card components rather than a third one, and a field added to a feed row or a Plex card +still gets added in exactly one place. CLAUDE.md's "not wanted" list names a base serializer for +precisely this reason. + +`aspect` is the one genuinely new field. A rail has to know whether a tile is a 16:9 still or a 2:3 +poster, and that is a property of the ARTWORK, not of the module: a Plex episode ships a landscape +still exactly like a YouTube video does, while a movie and a show ship posters. Emitting it lets the +rail layer stay domain-free instead of branching on `source`. + +`subtitle` only ever carries DATA — a channel name, a show title, a year. Nothing here composes an +English phrase: this repo is HU+EN with zero key drift, and a sentence assembled in the backend is a +string no translation file can reach. Anything that needs wording (season counts, "N videó") is left +to the client, which has both the native payload and the translations. +""" + +from app.discover.budget import remaining_seconds +from app.models import Channel, Playlist + +# Plex artwork is a poster for the things you browse and a landscape still for the things you play. +_POSTER_KINDS = ("movie", "show") + + +def _base( + *, + source: str, # "youtube" | "plex" + kind: str, # "video" | "movie" | "show" | "episode" | "channel" | "playlist" + id: str, + title: str | None, + aspect: str, # "wide" (16:9) | "poster" (2:3) + subtitle: str | None = None, + thumb: str | None = None, + duration_seconds: int | None = None, + position_seconds: int = 0, + status: str = "new", +) -> dict: + return { + "source": source, + "kind": kind, + "id": id, + "title": title or "", + "subtitle": subtitle, + "thumb": thumb, + "aspect": aspect, + "duration_seconds": duration_seconds, + "position_seconds": position_seconds, + "remaining_seconds": remaining_seconds(duration_seconds, position_seconds), + "status": status, + # Native payloads — at most one is ever set. The client opens the player from these. + "video": None, + "plex": None, + } + + +def from_video(row: dict) -> dict: + """`row` is a `feedquery.serialize` dict — the same object the feed grid renders.""" + card = _base( + source="youtube", + kind="video", + id=row["id"], + title=row["title"], + aspect="wide", + subtitle=row.get("channel_title"), + thumb=row.get("thumbnail_url"), + duration_seconds=row.get("duration_seconds"), + position_seconds=row.get("position_seconds") or 0, + status=row.get("status") or "new", + ) + card["video"] = row + return card + + +def from_plex(row: dict) -> dict: + """`row` is a `plex.cards` dict (movie / show / leaf). Its own `type` is the kind.""" + kind = row.get("type") or "movie" + if kind == "episode": + subtitle = row.get("show_title") + elif kind == "show": + subtitle = None # season count lives in the native payload; the client words it + else: + subtitle = str(row["year"]) if row.get("year") else None + + card = _base( + source="plex", + kind=kind, + id=row["id"], + title=row.get("title"), + aspect="poster" if kind in _POSTER_KINDS else "wide", + subtitle=subtitle, + thumb=row.get("thumb"), + duration_seconds=row.get("duration_seconds"), + position_seconds=row.get("position_seconds") or 0, + status=row.get("status") or "new", + ) + card["plex"] = row + return card + + +def from_channel(ch: Channel, unwatched: int) -> dict: + """A pinned channel. It is not itself playable — the shelf resolves it to its newest unwatched + video on click (see `resolve.py`) — so the tile advertises what is waiting there instead of a + runtime it does not have.""" + card = _base( + source="youtube", + kind="channel", + id=ch.id, + title=ch.title, + aspect="wide", + thumb=ch.thumbnail_url, + ) + card["unwatched_count"] = unwatched + return card + + +def from_playlist(pl: Playlist, item_count: int, thumb: str | None) -> dict: + """A pinned playlist. `thumb` is its first item's thumbnail — a shelf of blank tiles would defeat + the point of a surface you are supposed to be able to scan at a glance.""" + card = _base( + source="youtube", + kind="playlist", + id=str(pl.id), + title=pl.name, + aspect="wide", + thumb=thumb, + ) + card["item_count"] = item_count + return card diff --git a/backend/app/discover/hero.py b/backend/app/discover/hero.py new file mode 100644 index 00000000..b9b7b086 --- /dev/null +++ b/backend/app/discover/hero.py @@ -0,0 +1,71 @@ +"""The hero: the single suggestion the Home surface leads with. + +One rule, in one function, because the page's entire promise is that it answers rather than offers. +Three tiers, tried in order: + +1. **resume** — something already started whose REMAINING time fits the budget. Almost always the + right answer: it is the thing the user themselves chose most recently, and finishing it costs no + decision at all. +2. **comfort** — a pinned shelf entry that fits. The user's own declared fallback, which beats + anything we could infer. +3. **surprise** — a seeded draw from what is unseen and fits. + +The tier travels with the card so the client can label it honestly ("Ott hagytad abba" vs "A +polcodról" vs "Meglepetés") instead of guessing from the shape of the payload — a kicker that says +"where you left off" over a film the user has never opened is exactly the kind of quiet lie the house +rules call a dishonest state. + +Budgets are matched against REMAINING time throughout (`budget.remaining_seconds`, which equals the +runtime for anything not yet started): with eight minutes left, a 90-minute film is a ten-minute +answer. +""" + +from sqlalchemy.orm import Session + +from app.discover import cards, pins, queries, rails +from app.discover.budget import fits +from app.models import PlexShow, User +from app.plex.lookups import by_rating_key + +HERO_TIERS = ("resume", "comfort", "surprise") + + +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. + Everything else already plays itself. None when the show has no episodes mirrored yet.""" + if not (card.get("source") == "plex" and card.get("kind") == "show"): + return card + 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 pick( + db: Session, + user: User, + budget: str, + seed: int, + *, + resume: list[dict] | None = None, + shelf: list[dict] | 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.""" + for card in rails.continue_watching(db, user) if resume is None else resume: + playable = _playable(db, user.id, card) + 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): + return {"tier": "comfort", "card": card} + + for card in rails.surprise(db, user, budget, seed, limit=1): + return {"tier": "surprise", "card": card} + + return None diff --git a/backend/app/discover/pins.py b/backend/app/discover/pins.py new file mode 100644 index 00000000..3152cc29 --- /dev/null +++ b/backend/app/discover/pins.py @@ -0,0 +1,154 @@ +"""The comfort shelf: pin a thing you can always fall back to, and have it be one click from playing. + +Five kinds are pinnable — a video, a YouTube channel, a Plex show, a Plex movie, a playlist — because +comfort media is almost always "put on X", a series or a channel, and only sometimes one particular +video. `models.ComfortPin` carries that union as five nullable FKs under a `num_nonnulls(...) = 1` +check; this module is where the union is spoken about in one vocabulary, so no call site invents a +second one. + +Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile — +a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first +unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is +short and hand-curated, and it buys the property the whole surface exists for: one click, no +intermediate page, no second decision. + +Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not +yours" and the route turns that into its 404 — core never imports routes. +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.discover import cards, queries +from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video +from app.plex import rollup +from app.plex.cards import leaf_card, show_card +from app.plex.lookups import by_rating_key + +# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place. +PIN_COLUMNS: dict[str, str] = { + "video": "video_id", + "channel": "channel_id", + "plex_item": "plex_item_id", + "plex_show": "plex_show_id", + "playlist": "playlist_id", +} +PIN_KINDS = tuple(PIN_COLUMNS) + + +def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, object] | None: + """Translate the client's (kind, ref) into the (column, value) the row stores, or None. + + The client speaks the ids the rest of the API speaks: a video id, a channel id, a Plex + `rating_key`, a playlist id. Plex's internal integer keys never leave the backend, so the + translation belongs here rather than in five call sites.""" + if kind == "video": + return ("video_id", ref) if db.get(Video, ref) is not None else None + if kind == "channel": + return ("channel_id", ref) if db.get(Channel, ref) is not None else None + if kind == "plex_item": + it = by_rating_key(db, PlexItem, ref) + return ("plex_item_id", it.id) if it is not None else None + if kind == "plex_show": + sh = by_rating_key(db, PlexShow, ref) + return ("plex_show_id", sh.id) if sh is not None else None + if kind == "playlist": + try: + pid = int(ref) + except ValueError: + return None + pl = db.get(Playlist, pid) + # A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY + # lives in the state tables). Someone else's list must read as "no such thing", not as a 403 — + # the same reason `owned_or_404` 404s rather than 403s. + return ("playlist_id", pl.id) if pl is not None and pl.user_id == user.id else None + return None + + +def list_pins(db: Session, user: User) -> list[ComfortPin]: + return list( + db.execute( + select(ComfortPin) + .where(ComfortPin.user_id == user.id) + .order_by(ComfortPin.position.asc(), ComfortPin.id.asc()) + ) + .scalars() + .all() + ) + + +def next_position(db: Session, user: User) -> int: + """New pins land at the end of the shelf.""" + return len(list_pins(db, user)) + + +def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None: + """One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK + cascade removes the pin itself, so this is only the window between a delete and a refetch).""" + card: dict | None = None + nxt: dict | None = None + + if pin.video_id is not None: + row = queries.yt_video_row(db, user, pin.video_id) + if row is None: + return None + card = cards.from_video(row) + nxt = dict(card) # a video plays itself + + elif pin.channel_id is not None: + ch = db.get(Channel, pin.channel_id) + if ch is None: + return None + card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id)) + row = queries.channel_next_video(db, user, ch.id) + nxt = cards.from_video(row) if row else None + + elif pin.plex_item_id is not None: + it = db.get(PlexItem, pin.plex_item_id) + if it is None: + return None + st = rollup.item_state_map(db, user.id, [it.id]).get(it.id) + card = cards.from_plex(leaf_card(it, st)) + nxt = dict(card) # a movie or a single episode plays itself + + elif pin.plex_show_id is not None: + sh = db.get(PlexShow, pin.plex_show_id) + if sh is None: + return None + status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") + card = cards.from_plex(show_card(sh, status)) + ep = queries.next_episode(db, user.id, sh.id) + nxt = cards.from_plex(ep) if ep else None + + elif pin.playlist_id is not None: + pl = db.get(Playlist, pin.playlist_id) + if pl is None: + return None + count, row = queries.playlist_summary(db, user, pl.id) + card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None) + nxt = cards.from_video(row) if row else None + + if card is None: + return None + # A COPY, never the card itself: a self-playing pin (a video, a movie) would otherwise make + # `card["next"] is card`, which serializes as a circular reference and 500s the whole shelf. The + # copy is taken before `pin_id`/`next` are attached, so `next` stays a plain playable card. + card["pin_id"] = pin.id + card["next"] = nxt + return card + + +def shelf(db: Session, user: User) -> list[dict]: + """The whole shelf, in the user's own order.""" + out = [] + for pin in list_pins(db, user): + card = _shelf_card(db, user, pin) + if card is not None: + out.append(card) + return out + + +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")] diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py new file mode 100644 index 00000000..c1136cc2 --- /dev/null +++ b/backend/app/discover/queries.py @@ -0,0 +1,309 @@ +"""The row fetchers behind the Home surface — one place where "what is startable right now" is +expressed as SQL, for both sources. + +Two rules shape this module: + +**Reuse the query layer, not the route.** The YouTube half calls `feedquery.filtered_query` / +`sort_keys`, so the Home surface inherits the feed's own definitions of subscribed, blocked, +explore-visible and in-progress for free, and cannot drift from them. The Plex half calls +`plex.rollup` and the `plex.cards` serializers rather than `routes/plex/discover.py`: that route +narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a +sidebar and wrong for a landing page that does not. + +**One seed drives both halves.** The feed orders a shuffle by `md5(id || seed)` so a keyset cursor +stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single +un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together — +otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side +here takes the feed's seeded-md5 idiom instead of `random()`. +""" + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.discover.budget import band +from app.feedquery import feed_columns, filtered_query, order_by, serialize, sort_keys +from app.models import ( + Channel, + PlaylistItem, + PlexItem, + PlexShow, + PlexState, + User, + Video, + VideoState, +) +from app.plex import rollup +from app.plex.cards import leaf_card, movie_card, show_card +from app.plex.query import apply_show_state_filter, content_libs + +# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose — +# the two surfaces should feel like the same app, and a rail is a rail. +RAIL_LIMIT = 20 + +# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's +# `_feed_params` defaults, because those are an HTTP contract that may change for the feed's own +# reasons; the landing page's scope should not move with them silently. +_FEED_BASE: dict = { + "tags": [], + "tag_modes": {}, + "untagged": False, + "channel_ids": [], + "q": None, + "min_duration": None, + "max_duration": None, + "max_age_days": None, + "published_after": None, + "published_before": None, + "show_normal": True, + "include_shorts": False, + "include_live": False, + "show": "unwatched", + "scope": "my", + "library_source": "organic", +} + + +def _seeded(col, seed: int): + """The feed's deterministic shuffle expression, reused for Plex so one seed moves both halves.""" + return func.md5(func.concat(col, str(seed))) + + +# ── YouTube ─────────────────────────────────────────────────────────────────────────────────────── + + +def yt_in_progress(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]: + """Started-but-not-finished videos, most recently progressed first, as (progressed_at, row). + + The predicate is `feedquery`'s `show="in_progress"` verbatim (a stored resume position, status not + watched/hidden) — but the query is built here rather than through `filtered_query` because this + rail wants an ordering by `progress_updated_at`, and that column lives on the state alias the feed + query keeps private. Driving from `video_states` is also the faster plan: it is a per-user table of + a few hundred rows, against a catalog of many thousands.""" + st = aliased(VideoState) + q = ( + select(*feed_columns(user, st), st.progress_updated_at.label("progressed_at")) + .join(Channel, Channel.id == Video.channel_id) + .join(st, and_(st.video_id == Video.id, st.user_id == user.id)) + .where( + st.position_seconds > 0, + st.status.notin_(("watched", "hidden")), + Video.unavailable_since.is_(None), + ) + .order_by(st.progress_updated_at.desc().nullslast()) + .limit(limit) + ) + return [(r.progressed_at, serialize(r)) for r in db.execute(q).all()] + + +def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) -> list[dict]: + """Unwatched videos from the user's own feed scope, in seeded-shuffle order, inside the budget. + + Shorts are opened up only for the shortest band: "I have ten minutes" is the one answer a Short is + actually a good response to, and the feed's own default keeps them out otherwise.""" + lo, hi = band(budget) + params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short") + query, rank_expr = filtered_query(db, user, **params) + keys = sort_keys("shuffle", seed, rank_expr) + # Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and + # hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must + # go: the resume tier has already had its chance at them, and it may have passed precisely because + # the REMAINING time did not fit the budget — offering the same item back as a surprise, whose + # runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question. + rows = db.execute(query.order_by(*order_by(keys)).limit(limit * 4)).all() + out = [serialize(r) for r in rows] + return [r for r in out if not r["position_seconds"]][:limit] + + +def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None: + """The newest unwatched video of a pinned channel — what "put on that channel" resolves to.""" + # `library_source="all"`: a pinned channel means the whole channel, including videos that only + # ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a + # statement about what belongs to a channel. + params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all") + query, rank_expr = filtered_query(db, user, **params) + keys = sort_keys("newest", 0, rank_expr) + row = db.execute(query.order_by(*order_by(keys)).limit(1)).first() + return serialize(row) if row is not None else None + + +def yt_video_row(db: Session, user: User, video_id: str) -> dict | None: + """One video as a feed row, with this user's watch state. + + Built directly rather than through `filtered_query` on purpose: a PINNED video must keep working + even when it falls outside the feed's scope — the user unsubscribed from the channel, or blocked + it, or the video only ever arrived through a search. The shelf is the surface you fall back to; + it silently losing an entry because a filter moved is exactly the failure it must not have.""" + st = aliased(VideoState) + row = db.execute( + select(*feed_columns(user, st)) + .join(Channel, Channel.id == Video.channel_id) + .outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id)) + .where(Video.id == video_id) + ).first() + return serialize(row) if row is not None else None + + +def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int: + """How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a + channel has no runtime of its own.""" + params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all") + query, _ = filtered_query(db, user, **params) + return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0) + + +def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]: + """(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or + its first item once the whole list is watched. The same row doubles as the tile's artwork, so a + shelf of playlists is scannable instead of a row of blank boxes.""" + st = aliased(VideoState) + rows = db.execute( + select(*feed_columns(user, st), PlaylistItem.position) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .join(Channel, Channel.id == Video.channel_id) + .outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id)) + .where(PlaylistItem.playlist_id == playlist_id) + .order_by(PlaylistItem.position.asc()) + ).all() + if not rows: + return 0, None + nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0]) + return len(rows), serialize(nxt) + + +# ── Plex ────────────────────────────────────────────────────────────────────────────────────────── + + +def _unseen_movie_conds(uid: int): + """A movie the user has neither finished nor hidden. A LEFT-JOIN-IS-NULL would also drop rows that + merely carry a resume position, which are exactly the ones Continue watching wants back.""" + seen = ( + select(1) + .select_from(PlexState) + .where( + PlexState.item_id == PlexItem.id, + PlexState.user_id == uid, + or_( + PlexState.status.in_(("watched", "hidden")), + # Started counts as "not a surprise" too — see the note in `yt_surprise`. + PlexState.position_seconds > 0, + ), + ) + .exists() + ) + return ~seen + + +def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]: + """In-progress movies + partially-watched shows as (progressed_at, card). + + Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a + NULL status still counts as started, so `notin_` alone would drop it), and the same shared + `rollup.agg_subquery` + `apply_show_state_filter` for shows, so the two rails cannot disagree on + what "in progress" means. What it does NOT inherit is that route's `LibraryFilters` narrowing: + this surface has no Plex sidebar to read a filter from.""" + movie_libs, show_libs = content_libs(db) + out: list[tuple[object, dict]] = [] + + if movie_libs: + mq = ( + select(PlexItem, PlexState) + .join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid)) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_libs), + PlexState.position_seconds > 0, + or_( + PlexState.status.is_(None), + PlexState.status.notin_(("watched", "hidden")), + ), + ) + .order_by(PlexState.progress_updated_at.desc().nullslast()) + .limit(limit) + ) + for it, st in db.execute(mq).all(): + out.append((st.progress_updated_at, movie_card(it, st))) + + if show_libs: + prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() + sq = apply_show_state_filter( + select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id), + prog.c.total, + prog.c.watched, + prog.c.inprog, + "in_progress", + ).where(PlexShow.library_id.in_(show_libs)) + for sh, resumed_at in db.execute( + sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit) + ).all(): + out.append((resumed_at, show_card(sh, "in_progress"))) + + return out + + +def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]: + """Unseen playable movies inside the budget, in seeded-shuffle order. + + Movies only. A show is not a thing you can start playing, and resolving every candidate show to + its next episode just to draw three cards would be a query per row for a rail whose whole value is + that it answers instantly — shows reach this surface through Continue watching and the comfort + shelf, which is where "put the next episode on" actually belongs. + + No `playable` filter, matching the Plex surprise rail: `PlexItem.playable` is a playback CLASS + ("direct" / "remux" / "transcode"), not a yes/no, and NULL there means "not probed yet" rather than + "will not play". Excluding un-probed items would quietly hide a fresh library from the one surface + that is supposed to have an answer. The class travels on the card, so the client still shows it.""" + movie_libs, _ = content_libs(db) + if not movie_libs: + return [] + lo, hi = band(budget) + q = ( + select(PlexItem) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_libs), + _unseen_movie_conds(uid), + ) + .order_by(_seeded(PlexItem.rating_key, seed)) + .limit(limit) + ) + if lo is not None: + q = q.where(PlexItem.duration_s >= lo) + if hi is not None: + q = q.where(PlexItem.duration_s <= hi) + items = db.execute(q).scalars().all() + states = rollup.item_state_map(db, uid, [it.id for it in items]) + return [movie_card(it, states.get(it.id)) for it in items] + + +def next_episode(db: Session, uid: int, show_id: int) -> dict | None: + """What "put that show on" means: the episode in progress, else the first unwatched one, else the + first — in broadcast order, the same `(season, episode)` ordering `detail.py::_episode_nav` uses. + + Returned as a `leaf_card` so it carries the show title the episode row itself does not.""" + rows = db.execute( + select(PlexItem, PlexState) + .outerjoin(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid)) + .where(PlexItem.show_id == show_id, PlexItem.kind == "episode") + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) + ).all() + if not rows: + return None + + def _started(st: PlexState | None) -> bool: + return bool(st and st.position_seconds > 0 and st.status not in ("watched", "hidden")) + + def _unseen(st: PlexState | None) -> bool: + return st is None or st.status not in ("watched", "hidden") + + pick = next((r for r in rows if _started(r[1])), None) + pick = pick or next((r for r in rows if _unseen(r[1])), None) + pick = pick or rows[0] + + ep, st = pick + show = db.get(PlexShow, show_id) + return leaf_card( + ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None + ) diff --git a/backend/app/discover/rails.py b/backend/app/discover/rails.py new file mode 100644 index 00000000..eac51240 --- /dev/null +++ b/backend/app/discover/rails.py @@ -0,0 +1,71 @@ +"""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 User + +# 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 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 too, and it is what the user + recognises); resolving it to an episode is the hero's job, where something actually has to play.""" + 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) + return [card for _, card in rows[:limit]] + + +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] diff --git a/backend/app/main.py b/backend/app/main.py index 1cfeccf8..1386116c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from app.routes import ( auth as auth_routes, channels, config as config_routes, + discover as discover_routes, downloads, feed, health, @@ -187,6 +188,7 @@ app.include_router(auth_routes.router) app.include_router(sync.router) app.include_router(tags.router) app.include_router(feed.router) +app.include_router(discover_routes.router) app.include_router(search_routes.router) app.include_router(me.router) app.include_router(notifications.router) diff --git a/backend/app/models.py b/backend/app/models.py index 8d16706f..02a2895a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -3,6 +3,7 @@ from datetime import date, datetime from sqlalchemy import ( BigInteger, Boolean, + CheckConstraint, Computed, Date, DateTime, @@ -1406,3 +1407,52 @@ class PlexPlaylistItem(Base): ForeignKey("plex_items.id", ondelete="CASCADE"), index=True ) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) + + +class ComfortPin(Base, TimestampMixin): + """One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X" (E24). + + Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string + pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is + usually "put on X", a series or a channel, not one particular video. A string ref would order and + hold anything, but it would also lose the foreign key: a Plex item dropped from the library, or an + unsubscribed channel, would leave a dead tile on the one surface whose whole job is to be + reliable. So instead: one nullable CASCADE-ing FK per pinnable kind, and a check constraint that + exactly one of them is set. + + That constraint is the house rule applied to a five-way union — the invariant lives in the schema, + not in a comment every insert site has to remember. `kind`/`ref` below derive FROM the columns, so + there is no second vocabulary to keep in sync. + + NULLs are DISTINCT in a Postgres unique index, so the five per-kind unique constraints each stop a + double-pin of the same thing without colliding across kinds.""" + + __tablename__ = "comfort_pins" + __table_args__ = ( + CheckConstraint( + "num_nonnulls(video_id, channel_id, plex_item_id, plex_show_id, playlist_id) = 1", + name="ck_comfort_pin_one_ref", + ), + UniqueConstraint("user_id", "video_id", name="uq_comfort_pin_video"), + UniqueConstraint("user_id", "channel_id", name="uq_comfort_pin_channel"), + UniqueConstraint("user_id", "plex_item_id", name="uq_comfort_pin_plex_item"), + UniqueConstraint("user_id", "plex_show_id", name="uq_comfort_pin_plex_show"), + UniqueConstraint("user_id", "playlist_id", name="uq_comfort_pin_playlist"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + + video_id: Mapped[str | None] = mapped_column(ForeignKey("videos.id", ondelete="CASCADE")) + channel_id: Mapped[str | None] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) + plex_item_id: Mapped[int | None] = mapped_column( + ForeignKey("plex_items.id", ondelete="CASCADE") + ) + plex_show_id: Mapped[int | None] = mapped_column( + ForeignKey("plex_shows.id", ondelete="CASCADE") + ) + playlist_id: Mapped[int | None] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE")) + + # 0-based shelf order. The shelf is short and hand-curated, so a dense integer the reorder + # endpoint rewrites wholesale beats a fractional index nobody would ever exercise here. + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py new file mode 100644 index 00000000..8ba888f2 --- /dev/null +++ b/backend/app/routes/discover.py @@ -0,0 +1,165 @@ +"""E24 · the Home surface's HTTP shell. + +Thin by construction: every decision — what counts as in progress, what fits a time budget, which +tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask +the same questions without a request. This file validates, gates and serializes. + +The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a +landing page that fires four requests to answer one question would spend its whole budget on the +thing it exists to remove. +""" + +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.auth import current_user, require_human +from app.db import get_db +from app.discover import hero as hero_mod +from app.discover import pins, rails +from app.discover.budget import Budget, DEFAULT_BUDGET +from app.models import ComfortPin, User +from app.routes._common import owned_or_404 + +router = APIRouter(prefix="/api/discover", tags=["discover"]) + + +class PinIn(BaseModel): + kind: Literal["video", "channel", "plex_item", "plex_show", "playlist"] + # The id the rest of the API speaks for that kind: a video id, a channel id, a Plex rating_key, + # or a playlist id. `pins.resolve_ref` does the translation to the row's internal key. + ref: str + + +class ReorderIn(BaseModel): + ids: list[int] + + +@router.get("") +def get_discover( + budget: Budget = DEFAULT_BUDGET, + seed: int = 0, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Everything the Home page renders, in one pass. + + `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) + return { + "budget": budget, + "hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf), + "continue_watching": resume, + "comfort": shelf, + } + + +@router.get("/pick") +def get_pick( + budget: Budget = DEFAULT_BUDGET, + seed: int = 0, + 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)} + + +@router.get("/pins") +def list_pins( + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """The shelf on its own — for the shelf editor, which needs it without the rest of the page.""" + return {"items": pins.shelf(db, user)} + + +@router.post("/pins") +def add_pin( + body: PinIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """Pin something to the comfort shelf. + + `require_human`, like every other personal-state write: the shared demo account must not be able + to curate a communal shelf. Re-pinning something already on the shelf is a no-op that returns the + existing pin rather than a 409 — the gesture is "make sure this is on my shelf", and the unique + constraint is the mechanism, not the message.""" + resolved = pins.resolve_ref(db, user, body.kind, body.ref) + if resolved is None: + raise HTTPException(status_code=404, detail="Unknown item") + column, value = resolved + + existing = db.execute( + select(ComfortPin).where( + ComfortPin.user_id == user.id, + getattr(ComfortPin, column) == value, + ) + ).scalar_one_or_none() + if existing is not None: + return {"ok": True, "id": existing.id, "created": False} + + pin = ComfortPin(user_id=user.id, position=pins.next_position(db, user), **{column: value}) + db.add(pin) + try: + db.commit() + except IntegrityError: + # A concurrent pin of the same thing from a second tab. The unique constraint did its job; + # the user's intent ("be on my shelf") is satisfied either way, so report the winner rather + # than a 500 the user can do nothing with. + db.rollback() + winner = db.execute( + select(ComfortPin).where( + ComfortPin.user_id == user.id, + getattr(ComfortPin, column) == value, + ) + ).scalar_one_or_none() + if winner is None: + raise + return {"ok": True, "id": winner.id, "created": False} + return {"ok": True, "id": pin.id, "created": True} + + +@router.delete("/pins/{pin_id}") +def remove_pin( + pin_id: int, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + pin = owned_or_404(db, ComfortPin, pin_id, user) + db.delete(pin) + db.commit() + return {"deleted": pin_id} + + +@router.post("/pins/reorder") +def reorder_pins( + body: ReorderIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """Rewrite the shelf order from a full list of pin ids. + + 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) + ) + db.commit() + return {"ok": True} diff --git a/backend/tests/test_discover.py b/backend/tests/test_discover.py new file mode 100644 index 00000000..318f5301 --- /dev/null +++ b/backend/tests/test_discover.py @@ -0,0 +1,529 @@ +"""E24 S1 — the Home surface's read-model. + +Covers the four things that can silently rot here: the time-budget bands (pure), the cross-source +resume rail's merge order, the hero's tier ladder, and the comfort shelf (its schema-level one-of +invariant, its cascade, and the resolved `next` that makes one click enough). + +DB lane (`db` fixture) except for the budget block — skipped without Postgres. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import delete, func, select, text +from sqlalchemy.exc import IntegrityError + +from app.discover import hero, pins, queries, rails +from app.discover.budget import BANDS, fits, remaining_seconds +from app.models import ( + Channel, + ComfortPin, + PlexItem, + PlexLibrary, + PlexShow, + PlexState, + Playlist, + PlaylistItem, + Subscription, + User, + Video, + VideoState, +) + +NOW = datetime(2026, 6, 1, tzinfo=timezone.utc) + + +# ── budget (pure) ───────────────────────────────────────────────────────────────────────────────── + + +def test_bands_cover_every_runtime_and_overlap_deliberately(): + """Every runtime has a home in `any`, and the bands overlap so "30 perc" is not answered with a + two-minute Short just because it fits under the cap.""" + assert fits(3, "any") and fits(10_000, "any") + assert fits(2 * 60, "short") and not fits(2 * 60, "medium") # a Short is not a 30-minute answer + assert fits(30 * 60, "medium") and not fits(30 * 60, "short") + assert fits(110 * 60, "long") and not fits(110 * 60, "medium") + + +def test_unknown_duration_only_fits_the_unbounded_band(): + """We cannot promise an unknown-length item finishes inside ten minutes, so it is not offered as + an answer to a bounded question — the one control on the page has to mean what it says.""" + for budget in BANDS: + assert fits(None, budget) is (budget == "any") + + +def test_budget_is_matched_against_what_is_LEFT(): + """A 90-minute film with 8 minutes to go is a ten-minute answer.""" + assert remaining_seconds(90 * 60, 82 * 60) == 8 * 60 + assert fits(remaining_seconds(90 * 60, 82 * 60), "short") + assert not fits(remaining_seconds(90 * 60, 0), "short") + assert remaining_seconds(None, 30) is None + assert remaining_seconds(600, 900) == 0 # a position past the end never goes negative + + +# ── fixtures ────────────────────────────────────────────────────────────────────────────────────── + + +def _seed_yt(db) -> tuple[User, Channel]: + """A subscribed channel with three videos of distinct lengths, so a budget can pick between them.""" + user = User(email="discover-e24@example.com") + ch = Channel(id="UCe24", title="Comfort Channel", handle="@comfort") + db.add_all([user, ch]) + db.flush() + db.add(Subscription(user_id=user.id, channel_id=ch.id)) + db.add_all( + [ + Video( + id="vshort", + channel_id=ch.id, + title="Ten minute video", + duration_seconds=9 * 60, + published_at=NOW - timedelta(days=1), + ), + Video( + id="vmed", + channel_id=ch.id, + title="Half hour video", + duration_seconds=30 * 60, + published_at=NOW - timedelta(days=2), + ), + Video( + id="vlong", + channel_id=ch.id, + title="Feature length video", + duration_seconds=95 * 60, + published_at=NOW - timedelta(days=3), + ), + ] + ) + db.commit() + return user, ch + + +def _seed_plex(db, user: User) -> tuple[PlexItem, PlexShow, PlexItem]: + lib_m = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True) + lib_s = PlexLibrary(plex_key="91", title="Shows", kind="show", enabled=True) + db.add_all([lib_m, lib_s]) + db.flush() + movie = PlexItem( + rating_key="pm1", + library_id=lib_m.id, + kind="movie", + title="A Movie", + duration_s=100 * 60, + playable="direct", + ) + show = PlexShow(rating_key="ps1", library_id=lib_s.id, title="A Show", child_count=1) + db.add_all([movie, show]) + db.flush() + ep1 = PlexItem( + rating_key="ps1e1", + library_id=lib_s.id, + kind="episode", + title="Ep 1", + show_id=show.id, + season_number=1, + episode_number=1, + duration_s=45 * 60, + ) + ep2 = PlexItem( + rating_key="ps1e2", + library_id=lib_s.id, + kind="episode", + title="Ep 2", + show_id=show.id, + season_number=1, + episode_number=2, + duration_s=45 * 60, + ) + db.add_all([ep1, ep2]) + db.commit() + return movie, show, ep1 + + +def _enable_plex(db): + from app import sysconfig + + sysconfig.set_value(db, "plex_enabled", True) + db.commit() + + +# ── continue watching ───────────────────────────────────────────────────────────────────────────── + + +def test_continue_watching_merges_both_sources_by_recency(db): + """One rail, not two lists side by side — and ordered by when the user last touched each thing, + across sources. Two side-by-side rails would just move the decision up a level.""" + user, _ = _seed_yt(db) + movie, _, _ = _seed_plex(db, user) + _enable_plex(db) + db.add_all( + [ + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW - timedelta(hours=5), + ), + PlexState( + user_id=user.id, + item_id=movie.id, + status="new", + position_seconds=600, + progress_updated_at=NOW - timedelta(hours=1), + ), + ] + ) + db.commit() + + got = rails.continue_watching(db, user) + assert [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")] + assert got[0]["remaining_seconds"] == 100 * 60 - 600 + + +def test_continue_watching_is_youtube_only_when_plex_is_off(db): + """Plex is a deployment toggle; with it off the surface says nothing about Plex at all.""" + user, _ = _seed_yt(db) + movie, _, _ = _seed_plex(db, user) + db.add_all( + [ + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW, + ), + PlexState( + user_id=user.id, + item_id=movie.id, + status="new", + position_seconds=600, + progress_updated_at=NOW, + ), + ] + ) + db.commit() + assert {c["source"] for c in rails.continue_watching(db, user)} == {"youtube"} + + +def test_a_null_timestamp_sorts_last_instead_of_raising(db): + """The `(ts is not None, ts)` key this rail's Plex sibling used compares `None < None` when two + rows both lack a timestamp, which raises. A missing progress timestamp must sort last, not 500 + the landing page.""" + user, _ = _seed_yt(db) + db.add_all( + [ + VideoState( + user_id=user.id, video_id="vmed", status="new", position_seconds=1 + ), # no progress_updated_at + VideoState( + user_id=user.id, video_id="vlong", status="new", position_seconds=1 + ), # nor here + VideoState( + user_id=user.id, + video_id="vshort", + status="new", + position_seconds=1, + progress_updated_at=NOW, + ), + ] + ) + db.commit() + got = rails.continue_watching(db, user) + assert got[0]["id"] == "vshort" + assert len(got) == 3 + + +# ── hero ────────────────────────────────────────────────────────────────────────────────────────── + + +def test_hero_prefers_resume_and_labels_the_tier(db): + user, _ = _seed_yt(db) + db.add( + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW, + ) + ) + db.commit() + got = hero.pick(db, user, "any", seed=1) + assert got["tier"] == "resume" + assert got["card"]["id"] == "vmed" + + +def test_hero_skips_a_resume_that_does_not_fit_the_budget(db): + """ "I have ten minutes" must not be answered with the 80 minutes left on a film, even though + that film is the most recent thing the user touched.""" + user, _ = _seed_yt(db) + db.add( + VideoState( + user_id=user.id, + video_id="vlong", + status="new", + position_seconds=60, + progress_updated_at=NOW, + ) + ) + db.commit() + got = hero.pick(db, user, "short", seed=1) + assert got["tier"] == "surprise" + assert got["card"]["duration_seconds"] <= BANDS["short"][1] + + +def test_hero_falls_back_to_the_shelf_before_guessing(db): + """The user's own declared fallback beats anything we could infer.""" + user, ch = _seed_yt(db) + db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) + db.commit() + got = hero.pick(db, user, "medium", seed=1) + assert got["tier"] == "comfort" + assert got["card"]["id"] == "vmed" + + +def test_hero_resolves_a_show_to_the_episode_that_would_play(db): + """A show has no Play button. The hero of a partially-watched show is its next episode.""" + 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="watched", + watched_at=NOW, + progress_updated_at=NOW, + ) + ) + db.commit() + got = hero.pick(db, user, "any", seed=1) + assert got["tier"] == "resume" + assert got["card"]["kind"] == "episode" + assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays + + +def test_hero_is_none_when_nothing_fits(db): + """An empty answer is an honest answer: the page renders its own empty state rather than being + handed a card that does not answer what was asked. Here the whole library is under 40 minutes, + so "egy este" has nothing to offer.""" + user, ch = _seed_yt(db) + db.execute(text("DELETE FROM videos WHERE id = 'vlong'")) + db.commit() + assert hero.pick(db, user, "long", seed=1) is None + + +def test_surprise_is_reproducible_for_a_seed(db): + """The reroll button depends on this: one seed drives both sources, so the same seed must give + the same answer and a different seed must be free to give another.""" + user, _ = _seed_yt(db) + a = rails.surprise(db, user, "any", seed=7, limit=3) + b = rails.surprise(db, user, "any", seed=7, limit=3) + assert [c["id"] for c in a] == [c["id"] for c in b] + seeds = {tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3)) for s in range(12)} + assert len(seeds) > 1 # the seed actually reaches the ordering + + +def test_surprise_never_offers_something_already_started(db): + """The resume tier may have skipped an item because its REMAINING time did not fit; handing the + same item back as "Meglepetés" — where the full runtime does fit — would contradict the question + the user just answered.""" + user, _ = _seed_yt(db) + db.add( + VideoState( + user_id=user.id, + video_id="vlong", + status="new", + position_seconds=90 * 60, + progress_updated_at=NOW, + ) + ) + db.commit() + assert "vlong" not in {c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5)} + + +def test_the_seed_decides_which_source_leads(db): + """With a pool of one, a fixed source order would mean the Plex library could never answer "egy + este" whenever a YouTube video also fit — the cross-source promise would quietly be a YouTube + promise.""" + user, _ = _seed_yt(db) + _seed_plex(db, user) + _enable_plex(db) + leaders = {rails.surprise(db, user, "any", s, limit=1)[0]["source"] for s in range(6)} + assert leaders == {"youtube", "plex"} + + +# ── comfort shelf ───────────────────────────────────────────────────────────────────────────────── + + +def test_a_pin_must_reference_exactly_one_thing(db): + """The union invariant lives in the schema, not in a comment at every insert site.""" + user, ch = _seed_yt(db) + for kwargs in ({}, {"video_id": "vmed", "channel_id": ch.id}): + db.add(ComfortPin(user_id=user.id, position=0, **kwargs)) + with pytest.raises(IntegrityError): + db.flush() + db.rollback() + + +def test_deleting_the_pinned_row_removes_the_pin(db): + """A film dropped from the library must not leave a dead tile on the one surface whose whole job + is to be reliable — which is why the shelf holds foreign keys, not strings.""" + user, _ = _seed_yt(db) + db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) + db.commit() + db.execute(text("DELETE FROM videos WHERE id = 'vmed'")) + db.commit() + assert db.execute(select(func.count()).select_from(ComfortPin)).scalar() == 0 + + +def test_shelf_resolves_a_channel_to_its_newest_unwatched_video(db): + """ "Put that channel on" has to be one click, so the tile carries what would play.""" + user, ch = _seed_yt(db) + db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0)) + db.commit() + (card,) = pins.shelf(db, user) + assert card["kind"] == "channel" + assert card["unwatched_count"] == 3 + assert card["next"]["id"] == "vshort" # newest + + +def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db): + user, _ = _seed_yt(db) + pl = Playlist(user_id=user.id, name="Elalvós") + db.add(pl) + db.flush() + db.add_all( + [ + PlaylistItem(playlist_id=pl.id, video_id="vshort", position=0), + PlaylistItem(playlist_id=pl.id, video_id="vmed", position=1), + ] + ) + db.add(VideoState(user_id=user.id, video_id="vshort", status="watched", watched_at=NOW)) + db.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0)) + db.commit() + (card,) = pins.shelf(db, user) + assert card["kind"] == "playlist" + assert card["item_count"] == 2 + assert card["next"]["id"] == "vmed" + + +def test_a_pinned_video_survives_leaving_the_feed_scope(db): + """The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed + from the channel is exactly the failure it must not have.""" + user, ch = _seed_yt(db) + db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) + db.commit() + db.execute(delete(Subscription)) + db.commit() + (card,) = pins.shelf(db, user) + assert card["id"] == "vmed" + assert card["next"]["id"] == "vmed" + + +def test_a_self_playing_pin_does_not_reference_itself(db): + """`next` must be a distinct dict even when the pin plays itself. Aliasing the card into its own + `next` is a cycle, and the serializer turns the whole shelf into a 500 over it.""" + user, _ = _seed_yt(db) + db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0)) + db.commit() + (card,) = pins.shelf(db, user) + assert card["next"] is not card + assert "next" not in card["next"] + + +def test_next_episode_prefers_the_one_in_progress(db): + """In progress beats "next unwatched": resuming is cheaper than starting.""" + user, _ = _seed_yt(db) + _, show, ep1 = _seed_plex(db, user) + ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one() + db.add_all( + [ + PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW), + PlexState(user_id=user.id, item_id=ep2.id, status="new", position_seconds=120), + ] + ) + db.commit() + assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2" + + +# ── HTTP surface ────────────────────────────────────────────────────────────────────────────────── + + +def test_discover_endpoint_answers_in_one_pass(db, as_user): + user, _ = _seed_yt(db) + db.add( + VideoState( + user_id=user.id, + video_id="vmed", + status="new", + position_seconds=300, + progress_updated_at=NOW, + ) + ) + db.commit() + r = as_user(user).get("/api/discover?budget=medium") + assert r.status_code == 200 + body = r.json() + assert body["budget"] == "medium" + assert body["hero"]["tier"] == "resume" + assert [c["id"] for c in body["continue_watching"]] == ["vmed"] + assert body["comfort"] == [] + + +def test_an_unknown_budget_is_refused_rather_than_silently_widened(db, as_user): + user, _ = _seed_yt(db) + assert as_user(user).get("/api/discover?budget=whenever").status_code == 422 + + +def test_pinning_twice_is_the_same_shelf(db, as_user): + """The gesture is "make sure this is on my shelf", so a re-pin is a no-op, not a 409.""" + user, _ = _seed_yt(db) + c = as_user(user) + first = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json() + second = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json() + assert first["created"] is True and second["created"] is False + assert first["id"] == second["id"] + assert len(c.get("/api/discover/pins").json()["items"]) == 1 + + +def test_pinning_something_that_does_not_exist_is_a_404(db, as_user): + user, _ = _seed_yt(db) + r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "nope"}) + assert r.status_code == 404 + + +def test_another_users_playlist_is_not_pinnable(db, as_user): + """404, not 403 — a 403 would confirm the id exists to a stranger.""" + user, _ = _seed_yt(db) + other = User(email="other-e24@example.com") + db.add(other) + db.flush() + pl = Playlist(user_id=other.id, name="Theirs") + db.add(pl) + db.commit() + r = as_user(user).post("/api/discover/pins", json={"kind": "playlist", "ref": str(pl.id)}) + assert r.status_code == 404 + + +def test_reorder_ignores_ids_that_are_not_yours(db, as_user): + """A stale tab replaying an order that includes a since-deleted pin should reorder what still + exists rather than fail wholesale.""" + user, ch = _seed_yt(db) + c = as_user(user) + a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).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, 9999, a]}).status_code == 200 + assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, a] + + +def test_deleting_a_pin_reports_what_went(db, as_user): + user, _ = _seed_yt(db) + c = as_user(user) + pin_id = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"] + 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"] == [] From f3c0b7be23be7de37ed2a755a06310d65dca79dd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 04:14:14 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(e24):=20the=20Home=20surface=20?= =?UTF-8?q?=E2=80=94=20one=20question,=20one=20answer,=20both=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the time-budget chips on top, one large hero that answers them, then the comfort shelf and a cross-source resume rail. Home is the landing — deliberately OVER the remembered last module, since landing on whatever you happened to close the app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an account back out (Settings toggle lands in S4). No third card component: each unified card carries the native feed / Plex payload, so the rails render through the existing VideoCard (`cardsSmall`) and PlexPosterCard, and `aspect` decides the tile shape without branching on source. Plex playback hands off to the Plex module via a new `jumpTo` type — the player lives there with its lazy hls.js and its provider tree, and mounting a second one on the landing page to save one hand-off would be the expensive way round. Four defects found by driving it in a real browser against the live account, each now pinned by a mutation-checked test: - **The reroll gave the same card back.** A new seed only re-draws the SURPRISE tier; the resume tier deterministically returns the most recently progressed thing that fits, so anyone with something half-finished pressed a dead button. `/pick` now takes an `exclude` list of `:` keys and the client keeps a rolling window of 8. Excluding a SHOW also excludes the episode it resolves to — otherwise the same suggestion comes back wearing a hat. - **"Nothing to suggest yet" flashed over a full library** whenever a reroll was in flight: the new query key has no data for a beat. An empty state standing in for a loading one is the dishonesty the house rules name outright. The hero now falls back to the loaded payload while a pick is pending, and only trusts a null once the pick has RESOLVED. - **A budget change replaced the page — chips included — with a spinner**, so the control you just clicked vanished under your cursor. `keepPreviousData`, the same reason the feed uses it. - **The hero printed twice**, once as itself and once as the first resume tile. Two smaller UI calls made from the same session: the budget caption sits NEXT to its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem page throws the answers to the far edge; and the feed takes the YouTube mark its own toolbar already uses, since Home now owns the house icon. Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780, pytest 989. Verified end to end on localdev against the real account: budget chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is a ten-minute answer), the reroll walks three distinct heroes, and a Plex show tile opens its drill-down in the Plex module. --- backend/app/discover/cards.py | 7 + backend/app/discover/hero.py | 27 +- backend/app/routes/discover.py | 18 +- backend/tests/test_discover.py | 56 +++ frontend/src/components/Home.dom.test.tsx | 184 ++++++++ frontend/src/components/Home.tsx | 446 ++++++++++++++++++ frontend/src/components/PlexBrowse.tsx | 6 +- frontend/src/components/PlexProvider.tsx | 9 +- frontend/src/components/moduleRegistry.tsx | 2 + .../src/components/navigation.dom.test.ts | 17 +- frontend/src/components/navigationInit.ts | 9 +- frontend/src/i18n/locales/en/home.json | 37 ++ frontend/src/i18n/locales/hu/home.json | 37 ++ frontend/src/lib/api.ts | 4 + frontend/src/lib/api/discover.ts | 98 ++++ frontend/src/lib/api/facade.test.ts | 1 + frontend/src/lib/modules.ts | 7 +- frontend/src/lib/pageMeta.test.ts | 1 + frontend/src/lib/queryKeys.ts | 6 + frontend/src/lib/storage.ts | 5 + frontend/src/lib/urlState.ts | 1 + 21 files changed, 964 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/Home.dom.test.tsx create mode 100644 frontend/src/components/Home.tsx create mode 100644 frontend/src/i18n/locales/en/home.json create mode 100644 frontend/src/i18n/locales/hu/home.json create mode 100644 frontend/src/lib/api/discover.ts 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 `: