From 5703c4d7136751eb85a8358b7a6a680478e7654c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 6 Sep 2026 03:31:09 +0200 Subject: [PATCH] fix(plex): a null progress timestamp must sort a rail row last, not raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routes/plex/discover.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 68c88937..5e8816a7 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -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]]