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:
@@ -43,8 +43,10 @@ def rollup(cards: list[dict]) -> dict:
|
|||||||
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
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
|
"""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
|
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
|
continue-watching rail, and (d) the browse "last watched" sort. `include_resumed` adds `resumed_at`
|
||||||
show's episodes, so the rail can order shows by how recently they were last progressed."""
|
= 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)
|
ps = aliased(PlexState)
|
||||||
cols = [
|
cols = [
|
||||||
PlexItem.show_id.label("show_id"),
|
PlexItem.show_id.label("show_id"),
|
||||||
@@ -63,7 +65,9 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
|||||||
).label("inprog"),
|
).label("inprog"),
|
||||||
]
|
]
|
||||||
if include_resumed:
|
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 (
|
return (
|
||||||
select(*cols)
|
select(*cols)
|
||||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ def unified_library(
|
|||||||
cast(null(), Integer).label("season_count"),
|
cast(null(), Integer).label("season_count"),
|
||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
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)
|
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||||
if tsq is not None
|
if tsq is not None
|
||||||
@@ -159,7 +162,7 @@ def unified_library(
|
|||||||
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
||||||
selects.append(mq)
|
selects.append(mq)
|
||||||
if want_shows and grid_field:
|
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)
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
wat = func.coalesce(agg.c.watched, 0)
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
inp = func.coalesce(agg.c.inprog, 0)
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
@@ -179,6 +182,9 @@ def unified_library(
|
|||||||
PlexShow.child_count.label("season_count"),
|
PlexShow.child_count.label("season_count"),
|
||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
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)
|
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||||
if tsq is not None
|
if tsq is not None
|
||||||
@@ -212,6 +218,7 @@ def unified_library(
|
|||||||
"rating": u.c.rating,
|
"rating": u.c.rating,
|
||||||
"duration": u.c.duration_s,
|
"duration": u.c.duration_s,
|
||||||
"release": u.c.rel,
|
"release": u.c.rel,
|
||||||
|
"lastwatched": u.c.last_watched,
|
||||||
}.get(sort, u.c.added_at)
|
}.get(sort, u.c.added_at)
|
||||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||||
order.append(u.c.rk.desc())
|
order.append(u.c.rk.desc())
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""S3: the "last watched" browse sort (`sort=lastwatched`). Movies rank by
|
||||||
|
coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes
|
||||||
|
(rollup.resumed_at). Never-watched titles sink to the bottom (nullslast). DB lane (`db` fixture) —
|
||||||
|
skipped without Postgres.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.state as state
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||||
|
|
||||||
|
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add_all([lib_m, lib_s])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Movies: newest resume, a watched-only (coalesce falls back to watched_at), an older resume, and a
|
||||||
|
# never-touched one.
|
||||||
|
mo_new = PlexItem(rating_key="mo_new", library_id=lib_m.id, kind="movie", title="Newest Resume")
|
||||||
|
mo_watched = PlexItem(
|
||||||
|
rating_key="mo_watched", library_id=lib_m.id, kind="movie", title="Watched Only"
|
||||||
|
)
|
||||||
|
mo_old = PlexItem(rating_key="mo_old", library_id=lib_m.id, kind="movie", title="Older Resume")
|
||||||
|
mo_never = PlexItem(rating_key="mo_never", library_id=lib_m.id, kind="movie", title="Never")
|
||||||
|
|
||||||
|
# Shows: a recently-resumed episode, a watched-only episode, and a never-watched show.
|
||||||
|
sh_recent = PlexShow(
|
||||||
|
rating_key="sh_recent", library_id=lib_s.id, title="Recent Show", child_count=1
|
||||||
|
)
|
||||||
|
sh_watched = PlexShow(
|
||||||
|
rating_key="sh_watched", library_id=lib_s.id, title="Watched Show", child_count=1
|
||||||
|
)
|
||||||
|
sh_never = PlexShow(
|
||||||
|
rating_key="sh_never", library_id=lib_s.id, title="Never Show", child_count=1
|
||||||
|
)
|
||||||
|
db.add_all([mo_new, mo_watched, mo_old, mo_never, sh_recent, sh_watched, sh_never])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
ep_recent = PlexItem(
|
||||||
|
rating_key="sh_recent_e1",
|
||||||
|
library_id=lib_s.id,
|
||||||
|
kind="episode",
|
||||||
|
title="E1",
|
||||||
|
show_id=sh_recent.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
ep_watched = PlexItem(
|
||||||
|
rating_key="sh_watched_e1",
|
||||||
|
library_id=lib_s.id,
|
||||||
|
kind="episode",
|
||||||
|
title="E1",
|
||||||
|
show_id=sh_watched.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
db.add_all([ep_recent, ep_watched])
|
||||||
|
|
||||||
|
user = User(email="lastwatched@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=mo_new.id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=100,
|
||||||
|
progress_updated_at=NOW,
|
||||||
|
),
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=mo_watched.id,
|
||||||
|
status="watched",
|
||||||
|
watched_at=NOW - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=mo_old.id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=100,
|
||||||
|
progress_updated_at=NOW - timedelta(hours=2),
|
||||||
|
),
|
||||||
|
# Show episodes.
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=ep_recent.id,
|
||||||
|
status="new",
|
||||||
|
position_seconds=50,
|
||||||
|
progress_updated_at=NOW - timedelta(minutes=30),
|
||||||
|
),
|
||||||
|
PlexState(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=ep_watched.id,
|
||||||
|
status="watched",
|
||||||
|
watched_at=NOW - timedelta(minutes=90),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _get(db, user, monkeypatch, path):
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
try:
|
||||||
|
return TestClient(app).get(path)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(resp):
|
||||||
|
return [i["id"] for i in resp.json()["items"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_movies_ordered_by_last_watched_desc(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
resp = _get(
|
||||||
|
db, user, monkeypatch, "/api/plex/library?scope=movie&sort=lastwatched&sort_dir=desc"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Newest resume, then the watched-only (coalesce → watched_at), then the older resume, then never.
|
||||||
|
assert _ids(resp) == ["mo_new", "mo_watched", "mo_old", "mo_never"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shows_ordered_by_last_watched_desc(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
resp = _get(
|
||||||
|
db, user, monkeypatch, "/api/plex/library?scope=show&sort=lastwatched&sort_dir=desc"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Recent resume first, then the watched-only show, then the never-watched one (nullslast).
|
||||||
|
assert _ids(resp) == ["sh_recent", "sh_watched", "sh_never"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_watched_mixed_scope_nullslast(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
resp = _get(
|
||||||
|
db, user, monkeypatch, "/api/plex/library?scope=both&sort=lastwatched&sort_dir=desc"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
ids = _ids(resp)
|
||||||
|
# Global recency order across both legs: mo_new (NOW) > sh_recent (−30m) > mo_watched (−1h) >
|
||||||
|
# sh_watched (−90m) > mo_old (−2h); the two never-watched titles sink to the bottom.
|
||||||
|
assert ids[:5] == ["mo_new", "sh_recent", "mo_watched", "sh_watched", "mo_old"]
|
||||||
|
assert set(ids[5:]) == {"mo_never", "sh_never"}
|
||||||
@@ -11,6 +11,7 @@ import { User, X } from "lucide-react";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { healPlexField } from "../lib/plexSearch";
|
import { healPlexField } from "../lib/plexSearch";
|
||||||
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
|
import type { SortState } from "../lib/columnSort";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
EMPTY_PLEX_FILTERS,
|
EMPTY_PLEX_FILTERS,
|
||||||
@@ -52,6 +53,13 @@ const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
|||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
|
|
||||||
|
// D3: the "last watched" sort auto-applied when you enter the in-progress watch-state view (most
|
||||||
|
// recently watched first). Frozen for the same reason as PLEX_SORT_FALLBACK (handed out by reference).
|
||||||
|
const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||||
|
key: "lastwatched",
|
||||||
|
dir: "desc" as const,
|
||||||
|
});
|
||||||
|
|
||||||
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
||||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||||
@@ -185,6 +193,25 @@ export default function PlexBrowse() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
|
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
|
||||||
|
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
|
||||||
|
// forces the sort, so a manual sort change while in the in-progress view still wins — it's just not
|
||||||
|
// carried back out (exit returns to the pre-entry preference). We stash the raw provider `sort`, not
|
||||||
|
// the scope-healed one, so the user's real preference round-trips.
|
||||||
|
const prevShow = useRef(show);
|
||||||
|
const preInProgressSort = useRef<SortState | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const was = prevShow.current;
|
||||||
|
if (was !== "in_progress" && show === "in_progress") {
|
||||||
|
preInProgressSort.current = sort;
|
||||||
|
setSort(LASTWATCHED_SORT);
|
||||||
|
} else if (was === "in_progress" && show !== "in_progress") {
|
||||||
|
if (preInProgressSort.current) setSort(preInProgressSort.current);
|
||||||
|
preInProgressSort.current = null;
|
||||||
|
}
|
||||||
|
prevShow.current = show;
|
||||||
|
}, [show, sort, setSort]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
||||||
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"added": "Recently added",
|
"added": "Recently added",
|
||||||
|
"lastwatched": "Last watched",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"rating": "IMDb rating",
|
"rating": "IMDb rating",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"added": "Nemrég hozzáadott",
|
"added": "Nemrég hozzáadott",
|
||||||
|
"lastwatched": "Utoljára nézve",
|
||||||
"title": "Cím",
|
"title": "Cím",
|
||||||
"year": "Év",
|
"year": "Év",
|
||||||
"rating": "IMDb pont",
|
"rating": "IMDb pont",
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import { healSort, type SortOption, type SortState } from "./columnSort";
|
|||||||
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
|
// SortControl. Direction lives in the SortState (no more sibling `sortDir` on PlexFilters); the wire
|
||||||
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
|
// call (`plexLibrary`) still splits it into `sort` + `sort_dir` at the server boundary.
|
||||||
|
|
||||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
const MOVIE_SORTS = [
|
||||||
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
"added",
|
||||||
|
"lastwatched",
|
||||||
|
"release",
|
||||||
|
"year",
|
||||||
|
"rating",
|
||||||
|
"duration",
|
||||||
|
"title",
|
||||||
|
] as const;
|
||||||
|
const SHOW_SORTS = ["added", "lastwatched", "release", "year", "rating", "title"] as const;
|
||||||
|
|
||||||
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
|
/** The sort keys a scope offers — movie libraries add `duration`; shows/both don't. */
|
||||||
function plexSortKeys(scope: string): string[] {
|
function plexSortKeys(scope: string): string[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user