diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py
index 5b567713..d1a26f00 100644
--- a/backend/app/routes/plex/discover.py
+++ b/backend/app/routes/plex/discover.py
@@ -22,7 +22,7 @@ from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
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 (
LibraryFilters,
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(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> list[dict]:
@@ -300,6 +342,7 @@ def discover(
return {
"scope": scope,
"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),
"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),
diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py
index 600df0dc..0643f588 100644
--- a/backend/tests/test_plex_discover.py
+++ b/backend/tests/test_plex_discover.py
@@ -6,6 +6,7 @@ without Postgres.
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
+from sqlalchemy import select
import app.state as state
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
+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):
# 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.
diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx
index f1848ae4..548f5314 100644
--- a/frontend/src/components/PlexDiscover.dom.test.tsx
+++ b/frontend/src/components/PlexDiscover.dom.test.tsx
@@ -77,6 +77,7 @@ const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
const fullPayload = {
scope: "both",
continue_watching: [card("cw1", "Resume Me")],
+ recently_watched: [card("rw1", "Watched Me")],
recently_added: [card("ra1", "Just Added")],
genres: [{ value: "Drama", count: 12, art: [] }],
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");
+ expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy();
expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy();
expect(screen.getByText("plex.discover.surprise")).toBeTruthy();
expect(screen.getByText("plex.discover.byGenre")).toBeTruthy();
@@ -110,6 +112,7 @@ describe("PlexDiscover", () => {
expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy();
// A card from each rail is present (title rendered in the poster label).
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("Random Pick").length).toBeGreaterThan(0);
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
@@ -215,6 +218,7 @@ describe("PlexDiscover", () => {
plexDiscover.mockResolvedValue({
scope: "both",
continue_watching: [],
+ recently_watched: [],
recently_added: [],
genres: [],
because_you_watched: null,
diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx
index b2267837..4b23af4f 100644
--- a/frontend/src/components/PlexDiscover.tsx
+++ b/frontend/src/components/PlexDiscover.tsx
@@ -143,6 +143,7 @@ export default function PlexDiscover({
const bw = d.because_you_watched;
const nothing =
d.continue_watching.length === 0 &&
+ d.recently_watched.length === 0 &&
d.recently_added.length === 0 &&
d.genres.length === 0 &&
surprise.length === 0 &&
@@ -165,6 +166,15 @@ export default function PlexDiscover({
/>
)}
+ {d.recently_watched.length > 0 && (
+
+ )}
+
{d.recently_added.length > 0 && (