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.
72 lines
3.3 KiB
Python
72 lines
3.3 KiB
Python
"""The Home surface's rails: cross-source lists, assembled from `queries.py` and emitted as unified
|
|
cards.
|
|
|
|
The whole point of this module is that a rail is ONE list, not a YouTube list beside a Plex list. Two
|
|
side-by-side rails would just move the choice the user could not make ("which half do I look at?") one
|
|
level up, which is the opposite of what this epic is for.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import sysconfig
|
|
from app.discover import cards, queries
|
|
from app.discover.queries import RAIL_LIMIT
|
|
from app.models import 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]
|