feat(plex): 'last watched' browse sort + auto-apply in the in-progress view (S3)

Add a 'lastwatched' sort key to the unified Plex browse. Movies rank by
coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across
their episodes (rollup.resumed_at, now broadened from progress-only so watched
episodes count too). Never-watched titles sink via nullslast.

Frontend auto-applies lastwatched:desc when entering the in-progress watch-state
view and restores the pre-entry sort on exit; a manual sort change while there
still wins. i18n HU+EN. Backend tests cover both union legs + mixed nullslast.
This commit is contained in:
2026-08-27 02:50:41 +02:00
parent 6c918d1dff
commit 8299ea72b7
7 changed files with 212 additions and 6 deletions
+7 -3
View File
@@ -43,8 +43,10 @@ def rollup(cards: list[dict]) -> dict:
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. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the
show's episodes, so the rail can order shows by how recently they were last progressed."""
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"),
@@ -63,7 +65,9 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
).label("inprog"),
]
if include_resumed:
cols.append(func.max(ps.progress_updated_at).label("resumed_at"))
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))
+8 -1
View File
@@ -138,6 +138,9 @@ def unified_library(
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
(
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
@@ -159,7 +162,7 @@ def unified_library(
mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id).subquery()
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
@@ -179,6 +182,9 @@ def unified_library(
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
(
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
@@ -212,6 +218,7 @@ def unified_library(
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())