feat(plex): add a "Recently watched" rail to Discover (D)

A new Discover rail listing what the user last FULLY watched, newest first: a movie as its
own card, a show as the specific EPISODE watched (carrying its show title via leaf_card). Sits
above Recently added. Movies honour the sidebar filters like the other rails; episodes carry no
filterable metadata so they don't. Clicking a card plays it (the existing onCard path already
routes episodes to the player). Bilingual heading added.

Verified live: the rail renders watched movies + episodes with Watched badges.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 13 discover tests (+ a
recently-watched movie+episode assertion).
This commit is contained in:
2026-08-23 04:08:28 +02:00
parent 15f5a5694a
commit c0d869089d
7 changed files with 80 additions and 1 deletions
+44 -1
View File
@@ -22,7 +22,7 @@ from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup from app.plex import rollup
from app.plex.cards import movie_card, show_card from app.plex.cards import leaf_card, movie_card, show_card
from app.plex.query import ( from app.plex.query import (
LibraryFilters, LibraryFilters,
apply_meta_filters, apply_meta_filters,
@@ -119,6 +119,48 @@ def _recently_added(
] ]
def _recently_watched(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> list[dict]:
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
rows: list[tuple[object, dict]] = [] # (watched_at, card)
if movie_ids:
mq = (
select(PlexItem, PlexState)
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
PlexState.status == "watched",
)
)
for it, st in db.execute(
_movie_meta(mq, p, lang, f)
.order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all():
rows.append((st.watched_at, leaf_card(it, st)))
if show_ids:
eq = (
select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
.join(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_ids),
PlexState.status == "watched",
)
.order_by(PlexState.watched_at.desc().nullslast())
.limit(RAIL_LIMIT)
)
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)
return [card for _, card in rows[:RAIL_LIMIT]]
def _continue_watching( def _continue_watching(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> list[dict]: ) -> list[dict]:
@@ -300,6 +342,7 @@ def discover(
return { return {
"scope": scope, "scope": scope,
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f), "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f), "recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang), "genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f), "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
+19
View File
@@ -6,6 +6,7 @@ without Postgres.
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select
import app.state as state import app.state as state
from app.auth import current_user from app.auth import current_user
@@ -204,6 +205,24 @@ def test_surprise_scope_and_bound(db, monkeypatch):
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
def test_recently_watched_movies_and_episodes(db, monkeypatch):
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
user = _seed(db)
ep2 = db.execute(
select(PlexItem).where(PlexItem.rating_key == "sh1e2")
).scalars().one()
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
db.commit()
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"]
by_id = {c["id"]: c for c in rw}
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
assert by_id["sh1e2"]["show_title"] == "A Show" # episode carries its show's title
# ep2 was watched at NOW (newest) → it leads over m1 (watched earlier in _seed... same NOW ok).
assert set(_ids(rw)) == {"m1", "sh1e2"}
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch): def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the # Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind. # in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
@@ -77,6 +77,7 @@ const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
const fullPayload = { const fullPayload = {
scope: "both", scope: "both",
continue_watching: [card("cw1", "Resume Me")], continue_watching: [card("cw1", "Resume Me")],
recently_watched: [card("rw1", "Watched Me")],
recently_added: [card("ra1", "Just Added")], recently_added: [card("ra1", "Just Added")],
genres: [{ value: "Drama", count: 12, art: [] }], genres: [{ value: "Drama", count: 12, art: [] }],
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
@@ -103,6 +104,7 @@ describe("PlexDiscover", () => {
/>, />,
); );
await screen.findByText("plex.discover.continueWatching"); await screen.findByText("plex.discover.continueWatching");
expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy();
expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy(); expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy();
expect(screen.getByText("plex.discover.surprise")).toBeTruthy(); expect(screen.getByText("plex.discover.surprise")).toBeTruthy();
expect(screen.getByText("plex.discover.byGenre")).toBeTruthy(); expect(screen.getByText("plex.discover.byGenre")).toBeTruthy();
@@ -110,6 +112,7 @@ describe("PlexDiscover", () => {
expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy(); expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy();
// A card from each rail is present (title rendered in the poster label). // A card from each rail is present (title rendered in the poster label).
expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0); expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0);
expect(screen.getAllByText("Watched Me").length).toBeGreaterThan(0);
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0); expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0); expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0); expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
@@ -215,6 +218,7 @@ describe("PlexDiscover", () => {
plexDiscover.mockResolvedValue({ plexDiscover.mockResolvedValue({
scope: "both", scope: "both",
continue_watching: [], continue_watching: [],
recently_watched: [],
recently_added: [], recently_added: [],
genres: [], genres: [],
because_you_watched: null, because_you_watched: null,
+10
View File
@@ -143,6 +143,7 @@ export default function PlexDiscover({
const bw = d.because_you_watched; const bw = d.because_you_watched;
const nothing = const nothing =
d.continue_watching.length === 0 && d.continue_watching.length === 0 &&
d.recently_watched.length === 0 &&
d.recently_added.length === 0 && d.recently_added.length === 0 &&
d.genres.length === 0 && d.genres.length === 0 &&
surprise.length === 0 && surprise.length === 0 &&
@@ -165,6 +166,15 @@ export default function PlexDiscover({
/> />
)} )}
{d.recently_watched.length > 0 && (
<CardRail
title={t("plex.discover.recentlyWatched")}
ariaLabel={t("plex.discover.recentlyWatched")}
cards={d.recently_watched}
{...railProps}
/>
)}
{d.recently_added.length > 0 && ( {d.recently_added.length > 0 && (
<CardRail <CardRail
title={t("plex.discover.recentlyAdded")} title={t("plex.discover.recentlyAdded")}
+1
View File
@@ -292,6 +292,7 @@
"discover": { "discover": {
"tab": "Discover", "tab": "Discover",
"continueWatching": "Continue watching", "continueWatching": "Continue watching",
"recentlyWatched": "Recently watched",
"recentlyAdded": "Recently added", "recentlyAdded": "Recently added",
"surprise": "Surprise me", "surprise": "Surprise me",
"reshuffle": "Reshuffle", "reshuffle": "Reshuffle",
+1
View File
@@ -292,6 +292,7 @@
"discover": { "discover": {
"tab": "Felfedezés", "tab": "Felfedezés",
"continueWatching": "Folytatás", "continueWatching": "Folytatás",
"recentlyWatched": "Nemrég megnézve",
"recentlyAdded": "Nemrég hozzáadva", "recentlyAdded": "Nemrég hozzáadva",
"surprise": "Lepj meg", "surprise": "Lepj meg",
"reshuffle": "Újrakeverés", "reshuffle": "Újrakeverés",
+1
View File
@@ -343,6 +343,7 @@ export interface PlexPlaySession {
export interface PlexDiscover { export interface PlexDiscover {
scope: string; scope: string;
continue_watching: PlexCard[]; continue_watching: PlexCard[];
recently_watched: PlexCard[]; // last fully-watched movies + episodes, newest first
recently_added: PlexCard[]; recently_added: PlexCard[];
// top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage // top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage
genres: { value: string; count: number; art: string[] }[]; genres: { value: string; count: number; art: string[] }[];