The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py. The plex package joins FENCED: it reads no other unit's tables, says whose a row is through userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes. A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins. Pins 178 -> 0; basedpyright baseline 118 -> 106.
106 lines
4.8 KiB
Python
106 lines
4.8 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 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
|
|
from app.plex.models import PlexShow
|
|
from app.plex.lookups import by_rating_key
|
|
from app.plex.rollup import NO_TS
|
|
|
|
|
|
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 playable(db: Session, uid: int, card: dict, *, lang: str) -> dict | None:
|
|
"""The thing that would actually START from this card — itself for anything with a runtime, and
|
|
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
|
|
|
|
ONE function, because three surfaces need the same answer and a second implementation is how the
|
|
shelf and the rail came to disagree in the first place: the hero calls it to decide what it is
|
|
offering, `continue_watching` calls it to fill each show tile's `next`, and the comfort shelf
|
|
resolves the same way in `pins`. None when the show has no episodes mirrored yet.
|
|
|
|
A card that already carries a resolved `next` (a rail row) is taken at its word rather than
|
|
re-queried — the hero reads the rail it was handed."""
|
|
if not (card.get("source") == "plex" and card.get("kind") == "show"):
|
|
return card
|
|
if "next" in card:
|
|
return card["next"]
|
|
sh = by_rating_key(db, PlexShow, card["id"])
|
|
if sh is None:
|
|
return None
|
|
ep = queries.next_episode(db, uid, sh.id, lang=lang)
|
|
return cards.from_plex(ep) if ep else None
|
|
|
|
|
|
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT, *, lang: str) -> 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, and it is the artwork the
|
|
user recognises — but it carries `next`: the episode that would actually start. Without it a
|
|
click on a half-watched series landed on the show page with six seasons to choose between, which
|
|
is the decision this whole surface exists to remove (the comfort shelf and the hero already
|
|
resolved; only this rail did not). The client plays `next ?? card`, so the tile stays a poster and
|
|
the click stays one click."""
|
|
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, lang=lang)
|
|
]
|
|
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
|
|
out = [card for _, card in rows[:limit]]
|
|
# Resolve AFTER the slice: only the rows that survive it are worth a query.
|
|
for card in out:
|
|
if card["kind"] == "show":
|
|
card["next"] = playable(db, user.id, card, lang=lang)
|
|
return out
|
|
|
|
|
|
def surprise(
|
|
db: Session, user: User, budget: str, seed: int, limit: int = 3, *, lang: str
|
|
) -> 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 `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, lang=lang)
|
|
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]
|