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.
522 lines
20 KiB
Python
522 lines
20 KiB
Python
"""Characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion.
|
|
|
|
These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library
|
|
union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup
|
|
aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conversion can be proven
|
|
behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane
|
|
(`db` fixture) — skipped without Postgres.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import delete, 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, PlexSeason, PlexShow, PlexState, User
|
|
from app.plex import rollup, sync
|
|
|
|
|
|
def _dt(day: int) -> datetime:
|
|
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
|
|
|
|
|
def _seed(db):
|
|
"""A minimal cross-library corpus: 2 movies, 1 show with 2 episodes, and a per-user watch-state
|
|
where movie m1 is watched and episode e1 is in-progress (so the show rolls up to in_progress)."""
|
|
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
|
db.add_all([movie_lib, show_lib])
|
|
db.flush()
|
|
m1 = PlexItem(
|
|
rating_key="m1",
|
|
library_id=movie_lib.id,
|
|
kind="movie",
|
|
title="Alpha Movie",
|
|
year=2001,
|
|
added_at=_dt(1),
|
|
)
|
|
m2 = PlexItem(
|
|
rating_key="m2",
|
|
library_id=movie_lib.id,
|
|
kind="movie",
|
|
title="Bravo Movie",
|
|
year=2002,
|
|
added_at=_dt(2),
|
|
)
|
|
show = PlexShow(
|
|
rating_key="s1",
|
|
library_id=show_lib.id,
|
|
title="Charlie Show",
|
|
year=2003,
|
|
added_at=_dt(3),
|
|
child_count=1,
|
|
)
|
|
db.add_all([m1, m2, show])
|
|
db.flush()
|
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
|
db.add(season)
|
|
db.flush()
|
|
e1 = PlexItem(
|
|
rating_key="e1",
|
|
library_id=show_lib.id,
|
|
kind="episode",
|
|
title="Charlie Pilot",
|
|
show_id=show.id,
|
|
season_id=season.id,
|
|
season_number=1,
|
|
episode_number=1,
|
|
added_at=_dt(4),
|
|
)
|
|
e2 = PlexItem(
|
|
rating_key="e2",
|
|
library_id=show_lib.id,
|
|
kind="episode",
|
|
title="Charlie Finale",
|
|
show_id=show.id,
|
|
season_id=season.id,
|
|
season_number=1,
|
|
episode_number=2,
|
|
added_at=_dt(5),
|
|
)
|
|
db.add_all([e1, e2])
|
|
user = User(email="plexbrowse@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
db.add_all(
|
|
[
|
|
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
|
|
PlexState(user_id=user.id, item_id=e1.id, position_seconds=120),
|
|
]
|
|
)
|
|
db.commit()
|
|
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
|
|
|
|
|
|
def _request(db, user, monkeypatch, override_dep, method, path, json=None):
|
|
monkeypatch.setattr(
|
|
state, "_configured_cache", True
|
|
) # the request gate 503s an unconfigured app
|
|
override_dep(get_db, db.info["request"])
|
|
override_dep(current_user, lambda: user)
|
|
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
|
return client.request(method, path, json=json)
|
|
|
|
|
|
def _get(db, user, monkeypatch, override_dep, path):
|
|
return _request(db, user, monkeypatch, override_dep, "GET", path)
|
|
|
|
|
|
class TestUnifiedLibrary:
|
|
def test_both_scope_unions_movies_and_shows_ordered_by_added(
|
|
self, db, monkeypatch, override_dep
|
|
):
|
|
s = _seed(db)
|
|
r = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=both")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["total"] == 3 # 2 movies + 1 show, unioned
|
|
# default sort = added_at desc: show (day 3) > bravo (day 2) > alpha (day 1)
|
|
assert [i["id"] for i in body["items"]] == ["s1", "m2", "m1"]
|
|
show_item = next(i for i in body["items"] if i["type"] == "show")
|
|
assert show_item["status"] == "in_progress" # e1 in-progress -> show rollup
|
|
assert show_item["season_count"] == 1
|
|
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
|
|
|
|
def test_scope_movie_only(self, db, monkeypatch, override_dep):
|
|
s = _seed(db)
|
|
body = _get(
|
|
db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=movie"
|
|
).json()
|
|
assert body["total"] == 2
|
|
assert all(i["type"] == "movie" for i in body["items"])
|
|
|
|
def test_scope_show_only(self, db, monkeypatch, override_dep):
|
|
s = _seed(db)
|
|
body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?scope=show").json()
|
|
assert [i["id"] for i in body["items"]] == ["s1"]
|
|
assert body["items"][0]["season_count"] == 1
|
|
|
|
def test_watch_state_branches(self, db, monkeypatch, override_dep):
|
|
s = _seed(db)
|
|
# watched: only movie m1 (the show is in_progress, not fully watched)
|
|
watched = _get(
|
|
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=watched"
|
|
).json()
|
|
assert [i["id"] for i in watched["items"]] == ["m1"]
|
|
# in_progress: only the show (no movie is in progress)
|
|
inprog = _get(
|
|
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=in_progress"
|
|
).json()
|
|
assert [i["id"] for i in inprog["items"]] == ["s1"]
|
|
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means
|
|
# 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.)
|
|
unwatched = _get(
|
|
db, s["user"], monkeypatch, override_dep, "/api/plex/library?show=unwatched"
|
|
).json()
|
|
assert [i["id"] for i in unwatched["items"]] == ["m2"]
|
|
|
|
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch, override_dep):
|
|
s = _seed(db)
|
|
body = _get(db, s["user"], monkeypatch, override_dep, "/api/plex/library?q=charlie").json()
|
|
# only the show title matches in the item grid; the movies don't contain "charlie"
|
|
assert [i["id"] for i in body["items"]] == ["s1"]
|
|
assert body["total"] == 1
|
|
# matching episodes come back grouped in the separate episodes list
|
|
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
|
|
|
|
def test_pagination_caps_items_not_total(self, db, monkeypatch, override_dep):
|
|
s = _seed(db)
|
|
body = _get(
|
|
db, s["user"], monkeypatch, override_dep, "/api/plex/library?limit=2&offset=0"
|
|
).json()
|
|
assert body["total"] == 3
|
|
assert [i["id"] for i in body["items"]] == [
|
|
"s1",
|
|
"m2",
|
|
] # first page of the added-desc order
|
|
|
|
|
|
class TestRollup:
|
|
def test_state_map_new_inprogress_watched(self, db):
|
|
s = _seed(db)
|
|
show, user = s["show"], s["user"]
|
|
# e1 in-progress, e2 new -> in_progress
|
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
|
# empty input short-circuits
|
|
assert rollup.state_map(db, user.id, []) == {}
|
|
# a fresh user with no state -> new
|
|
other = User(email="rollup-other@example.com")
|
|
db.add(other)
|
|
db.commit()
|
|
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
|
# mark every episode watched for `user` -> watched
|
|
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
|
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
|
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
|
db.commit()
|
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
|
|
|
|
|
def _fake_paginate(by_type):
|
|
def _p(_plex, _key, typ, progress=None):
|
|
if progress is not None:
|
|
progress["complete"] = True # the fake returns the whole list — a complete listing
|
|
return list(by_type.get(typ, []))
|
|
|
|
return _p
|
|
|
|
|
|
class TestSyncUpsertIdempotency:
|
|
def test_movies_upsert_in_place_no_duplicates(self, db, monkeypatch):
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.commit()
|
|
metas = [
|
|
{"ratingKey": "100", "title": "First", "year": 2000, "addedAt": 1700000000},
|
|
{"ratingKey": "101", "title": "Second", "year": 2001, "addedAt": 1700000001},
|
|
]
|
|
monkeypatch.setattr(sync, "_paginate", _fake_paginate({sync._TYPE_MOVIE: metas}))
|
|
sync._sync_movies(db, object(), lib, {"movies": 0})
|
|
db.commit()
|
|
# second run with one title changed: same rows, updated in place (upsert, not re-insert)
|
|
metas[0]["title"] = "First Renamed"
|
|
sync._sync_movies(db, object(), lib, {"movies": 0})
|
|
db.commit()
|
|
rows = (
|
|
db.execute(
|
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows
|
|
assert next(r for r in rows if r.rating_key == "100").title == "First Renamed"
|
|
|
|
def test_shows_upsert_links_episodes_and_is_idempotent(self, db, monkeypatch):
|
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
|
db.add(lib)
|
|
db.commit()
|
|
by_type = {
|
|
sync._TYPE_SHOW: [
|
|
{"ratingKey": "s1", "title": "Show One", "childCount": 1, "addedAt": 1700000000}
|
|
],
|
|
sync._TYPE_SEASON: [
|
|
{"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1}
|
|
],
|
|
sync._TYPE_EPISODE: [
|
|
{
|
|
"ratingKey": "e1",
|
|
"title": "Ep One",
|
|
"grandparentRatingKey": "s1",
|
|
"parentRatingKey": "se1",
|
|
"parentIndex": 1,
|
|
"index": 1,
|
|
"addedAt": 1700000001,
|
|
}
|
|
],
|
|
}
|
|
monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type))
|
|
for _ in range(2): # run twice — must be idempotent
|
|
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
|
|
db.commit()
|
|
shows = db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars().all()
|
|
assert {sh.rating_key for sh in shows} == {"s1"} # no duplicate show
|
|
ep = db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars().one()
|
|
assert ep.rating_key == "e1"
|
|
assert ep.show_id == shows[0].id # episode linked to its show
|
|
assert (ep.season_number, ep.episode_number) == (1, 1)
|
|
|
|
|
|
class TestFacetsWithData:
|
|
"""The facets endpoint's wsq builder is the most complex converted query (genre/content-rating
|
|
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
|
|
covers the null-genre guard; this locks the aggregated numbers with real data."""
|
|
|
|
def test_facet_aggregation(self, db, monkeypatch, override_dep):
|
|
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
|
db.add_all([movie_lib, show_lib])
|
|
db.flush()
|
|
db.add_all(
|
|
[
|
|
PlexItem(
|
|
rating_key="m1",
|
|
library_id=movie_lib.id,
|
|
kind="movie",
|
|
title="M1",
|
|
genres=["Comedy", "Drama"],
|
|
content_rating="PG",
|
|
year=2000,
|
|
rating=7.5,
|
|
duration_s=6000,
|
|
),
|
|
PlexItem(
|
|
rating_key="m2",
|
|
library_id=movie_lib.id,
|
|
kind="movie",
|
|
title="M2",
|
|
genres=["Comedy"],
|
|
content_rating="R",
|
|
year=2010,
|
|
rating=8.0,
|
|
duration_s=7200,
|
|
),
|
|
PlexShow(
|
|
rating_key="s1",
|
|
library_id=show_lib.id,
|
|
title="S1",
|
|
genres=["Drama"],
|
|
content_rating="TV-14",
|
|
year=2005,
|
|
rating=9.0,
|
|
),
|
|
]
|
|
)
|
|
user = User(email="facetdata@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
body = _get(db, user, monkeypatch, override_dep, "/api/plex/facets?scope=both").json()
|
|
assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
|
|
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
|
|
"PG": 1,
|
|
"R": 1,
|
|
"TV-14": 1,
|
|
}
|
|
assert (body["year_min"], body["year_max"]) == (2000, 2010)
|
|
assert body["rating_max"] == 9.0
|
|
# Duration bounds are movie-only.
|
|
assert (body["duration_min"], body["duration_max"]) == (6000, 7200)
|
|
|
|
|
|
class TestPlaylistAndCollectionEndpoints:
|
|
"""Smoke coverage for the converted CRUD endpoints that had no tests: playlist create/list/detail/
|
|
add/reorder/remove/delete, and the collections list. A 1.x->2.0 conversion slip in any of these
|
|
(a lost .scalars(), a wrong .all(), a broken order_by) surfaces here instead of shipping green."""
|
|
|
|
def _movie(self, db, lib_id, rk, title):
|
|
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
|
|
|
|
def test_playlist_crud_roundtrip(self, db, monkeypatch, override_dep):
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add_all(
|
|
[self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")]
|
|
)
|
|
user = User(email="pl@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# create (seeded with m1) -> item_count 1
|
|
created = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
"/api/plex/playlists",
|
|
{"title": "Faves", "item_rating_key": "m1"},
|
|
)
|
|
assert created.status_code == 200, created.text
|
|
pid = created.json()["id"]
|
|
assert created.json()["item_count"] == 1
|
|
|
|
# list -> the playlist with its count
|
|
listed = _get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()[
|
|
"playlists"
|
|
]
|
|
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
|
|
|
|
# add m2, then reorder to [m2, m1]
|
|
add = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
f"/api/plex/playlists/{pid}/items",
|
|
{"item_rating_key": "m2"},
|
|
)
|
|
assert add.status_code == 200, add.text
|
|
_request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"PUT",
|
|
f"/api/plex/playlists/{pid}/order",
|
|
{"item_rating_keys": ["m2", "m1"]},
|
|
)
|
|
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
|
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
|
|
|
|
# remove m1 -> one item left
|
|
rm = _request(
|
|
db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}/items/m1"
|
|
)
|
|
assert rm.status_code == 200, rm.text
|
|
after = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
|
assert [it["id"] for it in after["items"]] == ["m2"]
|
|
|
|
# delete the playlist -> gone from the list
|
|
_request(db, user, monkeypatch, override_dep, "DELETE", f"/api/plex/playlists/{pid}")
|
|
assert (
|
|
_get(db, user, monkeypatch, override_dep, "/api/plex/playlists").json()["playlists"]
|
|
== []
|
|
)
|
|
|
|
def test_playlist_bulk_add_and_remove(self, db, monkeypatch, override_dep):
|
|
# Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's
|
|
# synchronize_session=False path, which the single add/remove roundtrip never exercises.
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")])
|
|
user = User(email="bulk@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
created = _request(
|
|
db, user, monkeypatch, override_dep, "POST", "/api/plex/playlists", {"title": "Bulk"}
|
|
)
|
|
pid = created.json()["id"]
|
|
|
|
added = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
f"/api/plex/playlists/{pid}/items/bulk",
|
|
{"item_rating_keys": ["m1", "m2", "m3"]},
|
|
)
|
|
assert added.status_code == 200, added.text
|
|
assert added.json() == {"ok": True, "added": 3, "item_count": 3}
|
|
|
|
removed = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
f"/api/plex/playlists/{pid}/items/remove-bulk",
|
|
{"item_rating_keys": ["m2"]},
|
|
)
|
|
assert removed.status_code == 200, removed.text
|
|
assert removed.json() == {"ok": True, "removed": 1, "item_count": 2}
|
|
|
|
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
|
assert [it["id"] for it in detail["items"]] == [
|
|
"m1",
|
|
"m3",
|
|
] # input order preserved, m2 gone
|
|
|
|
def test_collections_list(self, db, monkeypatch, override_dep):
|
|
from app.models import PlexCollection
|
|
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add_all(
|
|
[
|
|
PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12),
|
|
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5),
|
|
]
|
|
)
|
|
user = User(email="col@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
# unified scope (no library param) -> most-populated first
|
|
body = _get(db, user, monkeypatch, override_dep, "/api/plex/collections").json()
|
|
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
|
|
# name filter
|
|
filtered = _get(db, user, monkeypatch, override_dep, "/api/plex/collections?q=pix").json()
|
|
assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
|
|
|
|
def test_item_state_watched_then_new(self, db, monkeypatch, override_dep):
|
|
# No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which
|
|
# is what the conversion touched (the st lookup + the delete on ->new).
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
|
|
user = User(email="state@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
|
|
watched = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
"/api/plex/item/m1/state",
|
|
{"status": "watched"},
|
|
)
|
|
assert watched.status_code == 200, watched.text
|
|
assert watched.json()["status"] == "watched"
|
|
row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one()
|
|
assert row.status == "watched"
|
|
|
|
# -> new deletes the state row
|
|
back = _request(
|
|
db,
|
|
user,
|
|
monkeypatch,
|
|
override_dep,
|
|
"POST",
|
|
"/api/plex/item/m1/state",
|
|
{"status": "new"},
|
|
)
|
|
assert back.status_code == 200, back.text
|
|
assert (
|
|
db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first()
|
|
is None
|
|
)
|