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.
310 lines
14 KiB
Python
310 lines
14 KiB
Python
"""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
|
|
)
|