fix(plex): a null progress timestamp must sort a rail row last, not raise

The Continue-watching and Surprise rails sorted on `(ts is not None, ts)`. That
key LOOKS null-safe and is not: two rows that both lack a timestamp compare
equal on the flag and then compare `None < None`, which raises — so a show whose
rollup has no `resumed_at`, or a movie state written before
`progress_updated_at` existed, could 500 the rail instead of sorting last.

Replaced with an explicit epoch sentinel, and named it beside the other rail
constants so the sibling rail E24 adds cannot re-derive the same broken key.
This commit is contained in:
2026-09-06 03:31:09 +02:00
parent 013f19faca
commit 5703c4d713
+13 -4
View File
@@ -14,6 +14,8 @@ grid uses, applied to each rail's movie + show query (a rail may legitimately co
the client hides). Genre-language matches the grid; duration is movie-only.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -27,12 +29,13 @@ from app.plex.query import (
LibraryFilters,
apply_meta_filters,
apply_show_state_filter,
content_libs,
genres_col,
meta_filter_conds,
top_art_keys,
)
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
from app.routes.plex._shared import _norm_lang
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -40,10 +43,16 @@ RAIL_LIMIT = 20
GENRE_TILES = 12
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
# Sort sentinel for a row with no timestamp. The `(ts is not None, ts)` key this replaced LOOKS
# NULL-safe and is not: two null-timestamped rows compare equal on the flag and then compare
# `None < None`, which raises — so a show whose rollup has no `resumed_at`, or a movie state written
# before `progress_updated_at` existed, could 500 a rail rather than sort last (E24).
_NO_TS = datetime.min.replace(tzinfo=timezone.utc)
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
return (
movie_ids if scope in ("movie", "both") else [],
show_ids if scope in ("show", "both") else [],
@@ -170,7 +179,7 @@ def _recently_watched(
)
for ep, st, sh_title, sh_rk in db.execute(eq).all():
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
@@ -234,7 +243,7 @@ def _continue_watching(
for sh, resumed_at in srows:
rows.append((resumed_at, show_card(sh, "in_progress")))
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]