All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
325 lines
15 KiB
Python
325 lines
15 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]
|
|
|
|
|
|
# Watch states that take an episode out of the running for "what would play next".
|
|
_DONE = ("watched", "hidden")
|
|
|
|
|
|
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.
|
|
|
|
Three ordered `LIMIT 1` probes rather than one scan picked apart in Python. The Python version
|
|
loaded EVERY episode of the show to choose one of them — 236 rows for Friends, 227 for JAG — which
|
|
the comfort shelf could just about afford (a handful of hand-picked pins) and the resume rail
|
|
could not, since it resolves a show per rail row on the landing page.
|
|
|
|
Returned as a `leaf_card` so it carries the show title the episode row itself does not."""
|
|
base = (
|
|
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(),
|
|
)
|
|
.limit(1)
|
|
)
|
|
# In progress. `position_seconds > 0` already requires the state row to exist, so no null guard.
|
|
pick = db.execute(
|
|
base.where(PlexState.position_seconds > 0, PlexState.status.notin_(_DONE))
|
|
).first()
|
|
# Unwatched — INCLUDING an episode with no state row at all, which is the common case and the one
|
|
# a bare `status NOT IN (...)` silently drops (NULL is not false, it is unknown, so the row fails
|
|
# the filter).
|
|
pick = (
|
|
pick
|
|
or db.execute(
|
|
base.where(or_(PlexState.id.is_(None), PlexState.status.notin_(_DONE)))
|
|
).first()
|
|
)
|
|
# Fully watched show: start it again from the top.
|
|
pick = pick or db.execute(base).first()
|
|
if pick is None:
|
|
return None
|
|
|
|
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
|
|
)
|