"""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]