Files
siftlode/backend/tests/test_plex_discover.py
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

351 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail
payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB,
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
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
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _seed(db):
"""A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2
(in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode."""
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
m1 = PlexItem(
rating_key="m1",
library_id=lib_m.id,
kind="movie",
title="Anchor Movie",
guid="tmdb:111",
genres=["Drama"],
year=2001,
added_at=NOW - timedelta(days=1),
tmdb_enriched_at=NOW - timedelta(days=1),
recommendations=[
{"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"}
],
)
m2 = PlexItem(
rating_key="m2",
library_id=lib_m.id,
kind="movie",
title="Rec Movie",
guid="tmdb:555",
genres=["Comedy"],
year=2002,
added_at=NOW - timedelta(days=2),
)
m3 = PlexItem(
rating_key="m3",
library_id=lib_m.id,
kind="movie",
title="Half-Watched Movie",
guid="tmdb:333",
genres=["Drama"],
year=2003,
duration_s=6000,
added_at=NOW - timedelta(days=3),
)
sh1 = PlexShow(
rating_key="sh1",
library_id=lib_s.id,
title="A Show",
child_count=1,
added_at=NOW - timedelta(hours=1),
) # newest → leads recently-added
db.add_all([m1, m2, m3, sh1])
db.flush()
ep1 = PlexItem(
rating_key="sh1e1",
library_id=lib_s.id,
kind="episode",
title="Ep 1",
show_id=sh1.id,
season_number=1,
episode_number=1,
)
ep2 = PlexItem(
rating_key="sh1e2",
library_id=lib_s.id,
kind="episode",
title="Ep 2",
show_id=sh1.id,
season_number=1,
episode_number=2,
)
db.add_all([ep1, ep2])
user = User(email="discover@example.com")
db.add(user)
db.flush()
db.add_all(
[
# m1 watched most recently → the because-you-watched anchor.
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
# m3 in-progress → continue watching.
PlexState(
user_id=user.id,
item_id=m3.id,
status="new",
position_seconds=1200,
progress_updated_at=NOW - timedelta(hours=1),
),
# sh1's ep1 in-progress → the show is in continue watching.
PlexState(
user_id=user.id,
item_id=ep1.id,
status="new",
position_seconds=300,
progress_updated_at=NOW - timedelta(minutes=30),
),
]
)
db.commit()
return user
def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
client = TestClient(app) # no context manager: skip lifespan
return client.get(path)
def _ids(cards):
return [c["id"] for c in cards]
def test_discover_shape_and_recently_added_order(db, monkeypatch, override_dep):
user = _seed(db)
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both")
assert resp.status_code == 200, resp.text
d = resp.json()
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
# Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3.
assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"]
# Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2).
genres = {g["value"]: g["count"] for g in d["genres"]}
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
def test_genre_tiles_carry_poster_art(db, monkeypatch, override_dep):
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
art = {g["value"]: g["art"] for g in d["genres"]}
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
assert art["Comedy"] == ["m2"]
def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
ids = _ids(d["continue_watching"])
# The in-progress movie and the show with an in-progress episode are both present; the watched
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
assert ids == ["sh1", "m3"]
assert "m1" not in ids
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
# A hidden started movie must be excluded.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
m_null = PlexItem(
rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1"
)
m_hidden = PlexItem(
rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2"
)
db.add_all([m_null, m_hidden])
db.flush()
user = User(email="cw-null@example.com")
db.add(user)
db.flush()
db.add_all(
[
PlexState(
user_id=user.id,
item_id=m_null.id,
status=None,
position_seconds=500,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=m_hidden.id,
status="hidden",
position_seconds=500,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
ids = _ids(
_get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()[
"continue_watching"
]
)
assert "mn" in ids # NULL-status started movie IS in progress
assert "mh" not in ids # hidden is excluded
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch, override_dep):
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib_s)
db.flush()
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
db.add(sh)
db.flush()
ep = PlexItem(
rating_key="shwe1",
library_id=lib_s.id,
kind="episode",
title="E1",
show_id=sh.id,
season_number=1,
episode_number=1,
)
db.add(ep)
db.flush()
user = User(email="fullywatched@example.com")
db.add(user)
db.flush()
db.add(
PlexState(
user_id=user.id,
item_id=ep.id,
status="watched",
position_seconds=0,
progress_updated_at=NOW,
)
)
db.commit()
ids = _ids(
_get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=show").json()[
"continue_watching"
]
)
assert "shw" not in ids # fully watched → excluded from continue-watching
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch, override_dep):
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
bw = d["because_you_watched"]
assert bw is not None
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
# m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card.
assert _ids(bw["items"]) == ["m2"]
def test_because_you_watched_null_without_history(db, monkeypatch, override_dep):
_seed(db)
fresh = User(email="fresh-discover@example.com")
db.add(fresh)
db.commit()
d = _get(db, fresh, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
# A user with no watched-and-enriched movie gets no recommendation rail.
assert d["because_you_watched"] is None
assert d["continue_watching"] == []
def test_surprise_scope_and_bound(db, monkeypatch, override_dep):
user = _seed(db)
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&limit=2")
assert resp.status_code == 200, resp.text
items = resp.json()["items"]
assert len(items) <= 2
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
def test_recently_watched_movies_and_episodes(db, monkeypatch, override_dep):
# 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, override_dep, "/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, override_dep):
# 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.
user = _seed(db)
items = _get(db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=both").json()[
"items"
]
ids = _ids(items)
assert "m1" not in ids # watched → excluded
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
assert len([c for c in items if c["type"] == "movie"]) <= 3
assert len([c for c in items if c["type"] == "show"]) <= 3
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
assert all(c["type"] == "movie" for c in d["recently_added"])
# The show drops out of continue watching under the movie scope.
assert _ids(d["continue_watching"]) == ["m3"]
def test_filters_narrow_every_discover_rail(db, monkeypatch, override_dep):
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
user = _seed(db)
d = _get(
db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both&genres=Drama"
).json()
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
assert _ids(d["continue_watching"]) == ["m3"]
# Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
# Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
assert d["because_you_watched"] is None
def test_filters_narrow_surprise(db, monkeypatch, override_dep):
user = _seed(db)
items = _get(
db, user, monkeypatch, override_dep, "/api/plex/surprise?scope=movie&genres=Comedy"
).json()["items"]
# Only the one Comedy movie qualifies under the filter.
assert _ids(items) == ["m2"]