Files
siftlode/backend/app/plex/rollup.py
T
peter cedebe0ffb test(e24): close the review's coverage gaps, and correct a comment that was untrue
`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query
inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It
now seeds a video, a movie and an episode, and asserts all three kinds are present before
the loop, so it cannot go vacuous again.

The Plex half of the surprise rail had no test for either of its promises: every test that
reached `plex_surprise` passed budget="any" against a library with no state rows, so the
duration bounds and the already-started exclusion could both be deleted with the suite
green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half
of one principle.

`plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at
all, leaving the rating_key → internal-id translation and both shelf arms uncovered,
including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick`
was exercised only through its domain function, so the route's own query-string translation
could regress silently; the new test excludes all three candidates, which is what proves the
whole list parsed rather than one unsplit string.

`PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is
spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route
re-typed the kind list. Both now drive off the constants.

The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced,
`(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via
`==`, so two null keys compare equal and `None < None` is never reached; three independent
runs confirmed it over every permutation. The rationale now says what the sentinel actually
buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling
has been swept to match. Its test is renamed and re-scoped to the mutation it does catch.

Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an
accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each
reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was
untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player
typechecked and shipped green.

All eight fixes mutation-checked; every mutant died on its intended test.
2026-09-08 03:14:21 +02:00

112 lines
4.9 KiB
Python

"""Roll a show's episodes up into a single watch-state (new | in_progress | watched) + on-deck.
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
"""
from datetime import datetime, timezone
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
# same at every rail that merges two type-homogeneous lists in Python.
#
# It is NOT a crash fix, whatever the E24 commit that introduced it says. The `(ts is not None, ts)`
# key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two
# `(False, None)` keys compare equal and `None < None` is never reached — verified by execution
# during the E24 epic-end review, over every permutation of null/non-null rows. What the sentinel
# buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than
# encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the
# rails' tests do guard against.
NO_TS = datetime.min.replace(tzinfo=timezone.utc)
def show_status(total: int, watched: int, inprog: int) -> str:
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
if total and watched >= total:
return "watched"
if (watched or 0) > 0 or (inprog or 0) > 0:
return "in_progress"
return "new"
def rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
total = len(cards)
watched = sum(1 for c in cards if c.get("status") == "watched")
inprog = [
c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"
]
resume = None
if inprog:
resume = inprog[-1] # continue the latest-started episode
else:
resume = next((c for c in cards if c.get("status") != "watched"), None)
return {
"status": show_status(total, watched, len(inprog)),
"resume": resume,
"first": cards[0] if cards else None,
"episode_count": total,
}
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
continue-watching rail, and (d) the browse "last watched" sort. `include_resumed` adds `resumed_at`
= max(coalesce(progress_updated_at, watched_at)) across the show's episodes — i.e. how recently ANY
episode was last touched (resumed OR marked watched) — so callers can order shows by recency. A
fully-watched show (no resume progress) still gets a timestamp from its episodes' watched_at."""
ps = aliased(PlexState)
cols = [
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
)
).label("inprog"),
]
if include_resumed:
cols.append(
func.max(func.coalesce(ps.progress_updated_at, ps.watched_at)).label("resumed_at")
)
return (
select(*cols)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
)
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids:
return {}
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
if not item_ids:
return {}
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id.in_(item_ids))
).scalars()
}