"""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.userscope import owned_by from app.plex.models import PlexItem, PlexState from app.plex.cards import ShowState # 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 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 a 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 show_state(total: int, watched: int, inprog: int) -> ShowState: """`show_status` plus whether an episode holds a resume point (the card's `ShowState`).""" return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0) 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: # The LAST in-progress episode in broadcast order — no timestamp is consulted. Home's # `discover.queries.next_episode` picks the same one, so both Resume buttons agree. resume = inprog[-1] 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, owned_by(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, ShowState]: 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_state(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 `ShowState`). 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( owned_by(PlexState.user_id, user_id), PlexState.item_id.in_(item_ids) ) ).scalars() }