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
+19
View File
@@ -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.