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.
123 lines
5.3 KiB
Python
123 lines
5.3 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.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()
|
|
}
|