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 && (