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:
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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[] }[];
|
||||||
|
|||||||
Reference in New Issue
Block a user