refactor(plex): playlists, playback, Discover and admin through the unit; the plex package is fenced

The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds
SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a
user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a
season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py.

The plex package joins FENCED: it reads no other unit's tables, says whose a row is through
userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in
its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes.
A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins.

Pins 178 -> 0; basedpyright baseline 118 -> 106.
This commit is contained in:
2026-10-19 22:42:46 +02:00
parent 2cf1b688d9
commit 531eb2eef1
18 changed files with 1307 additions and 1052 deletions
+162 -3
View File
@@ -4,7 +4,8 @@ The routes are thin callers now, and their own tests stay the contract a client
what each read and write does on its own: that a user's watch state is theirs alone (another
user's state is the same answer as none), that a write leaves the commit to its caller, and the rule
each one carries (an explicit empty key list asks for nothing, a kept collection is editable and
holds its seed). DB-backed (real Postgres).
holds its seed, a finish scrobbles once, an unwatch keeps a hidden mark, a playlist add is
idempotent). DB-backed (real Postgres).
"""
from datetime import datetime, timezone
@@ -68,8 +69,8 @@ def test_a_users_watch_state_is_theirs_alone(db, make_user):
lib = _lib(db)
it = _movie(db, lib, "m1")
_state(db, owner, it, status="watched")
assert queries.state(db, owner, it).status == "watched"
assert queries.state(db, stranger, it) is None
assert queries.state(db, owner, it.id).status == "watched"
assert queries.state(db, stranger, it.id) is None
assert queries.states(db, stranger, [it.id]) == {}
assert set(queries.states(db, owner, [it.id])) == {it.id}
assert queries.states(db, owner, []) == {}
@@ -262,3 +263,161 @@ def test_marking_a_collection_editable_is_left_to_the_caller(db):
db.commit()
db.expire_all()
assert db.scalar(select(PlexCollection.editable)) is True
# --- playlists -------------------------------------------------------------------------------
def _items(db, lib, *rks):
return [_movie(db, lib, rk) for rk in rks]
def test_a_playlist_is_its_owners_alone(db, make_user):
owner, stranger = make_user(), make_user()
pl = service.create_playlist(db, owner, "Mine", None)
db.commit()
assert queries.own_playlist(db, stranger, pl.id) is None
assert queries.own_playlist(db, owner, pl.id).title == "Mine"
assert queries.playlists_of(db, stranger) == []
assert [p.id for p in queries.playlists_of(db, owner)] == [pl.id]
def test_playlist_adds_append_once_and_removes_stay_in_their_list(db, make_user):
user = make_user()
lib = _lib(db)
a, b, c = _items(db, lib, "a", "b", "c")
pl = service.create_playlist(db, user, "L", a)
other = service.create_playlist(db, user, "Other", b)
service.add_item(db, pl, b)
service.add_item(db, pl, a) # already in it: stays first
assert service.add_items(db, pl, [c, b]) == 1
db.commit()
assert [it.rating_key for it in queries.playlist_items(db, pl.id)] == ["a", "b", "c"]
assert queries.playlist_holds(db, pl.id, {a.id, c.id}) == 2
assert service.remove_items(db, pl, [b.id, 999]) == 1
service.remove_item(db, pl, c)
db.commit()
assert [it.rating_key for it in queries.playlist_items(db, pl.id)] == ["a"]
assert [it.rating_key for it in queries.playlist_items(db, other.id)] == ["b"]
assert queries.playlist_cover(db, other.id) == "b"
def test_an_undo_puts_items_back_at_their_places_and_renumbers(db, make_user):
user = make_user()
lib = _lib(db)
a, b, c, d = _items(db, lib, "a", "b", "c", "d")
pl = service.create_playlist(db, user, "L", a)
service.add_items(db, pl, [c])
assert service.insert_items_at(db, pl, [(3, d), (1, b), (0, a)]) == 2 # a is there already
db.commit()
assert [it.rating_key for it in queries.playlist_items(db, pl.id)] == ["a", "b", "c", "d"]
def test_a_reorder_keeps_rows_it_was_not_told_about_after_the_rest(db, make_user):
user = make_user()
lib = _lib(db)
a, b, c = _items(db, lib, "a", "b", "c")
pl = service.create_playlist(db, user, "L", a)
service.add_items(db, pl, [b, c])
service.reorder_playlist(db, pl, ["c", "b", "nope"]) # `a` (position 0) not named
db.commit()
assert [it.rating_key for it in queries.playlist_items(db, pl.id)] == ["c", "b", "a"]
assert queries.items_by_keys(db, ["b", "x", "b", "a"]) == [b, a]
# --- watch state -----------------------------------------------------------------------------
def test_a_finish_flips_once_and_leaves_another_users_state_alone(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
it = _movie(db, lib, "m1")
_state(db, other, it, status="new", position_seconds=50)
assert service.mark_finished(db, me.id, it.id, NOW) is True
db.commit()
assert service.mark_finished(db, me.id, it.id, NOW) is False
db.commit()
db.expire_all()
assert (
queries.state(db, other.id, it.id).status,
queries.state(db, other.id, it.id).position_seconds,
) == ("new", 50)
def test_an_early_checkpoint_drops_a_bare_state_and_zeroes_a_marked_one(db, make_user):
user = make_user()
lib = _lib(db)
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
st = service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
assert (st.position_seconds, st.synced_to_plex) == (120, False)
_state(db, user, hid, status="hidden", position_seconds=40)
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
service.clear_progress(db, queries.state(db, user, hid.id), NOW)
db.commit()
assert queries.state(db, user, bare.id) is None
assert (
queries.state(db, user, hid.id).status,
queries.state(db, user, hid.id).position_seconds,
) == ("hidden", 0)
def test_unwatching_a_season_keeps_hidden_marks_and_says_what_to_push(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
seen, hid, fresh = _items(db, lib, "seen", "hid", "fresh")
_state(db, me, seen, status="watched")
_state(db, me, hid, status="hidden")
_state(db, other, seen, status="watched")
assert service.bulk_state(db, me.id, [seen, hid, fresh], watched=False) == [
(seen.id, "seen", "unwatched")
]
db.commit()
assert queries.state(db, me, hid.id).status == "hidden"
assert queries.state(db, other, seen.id).status == "watched"
pushes = service.bulk_state(db, me.id, [seen, hid], watched=True)
db.commit()
assert pushes == [(seen.id, "seen", "watched"), (hid.id, "hid", "watched")]
def test_a_watch_link_is_made_once_and_is_its_owners(db, make_user):
me, other = make_user(), make_user()
first = service.set_watch_link(db, me, True)
again = service.set_watch_link(db, me, False)
db.commit()
assert first.id == again.id and again.sync_enabled is False and again.uses_admin is True
assert queries.watch_link(db, other) is None
# --- discover --------------------------------------------------------------------------------
def test_the_discover_rails_follow_the_callers_state(db, make_user):
from app.plex import discover
me, other = make_user(), make_user()
lib = _lib(db)
going, done = _movie(db, lib, "going"), _movie(db, lib, "done")
_state(db, other, going, status="new", position_seconds=300, progress_updated_at=NOW)
_state(db, other, done, status="watched", watched_at=NOW)
mine = discover.discover_page(db, me, "both", "en", LibraryFilters())
theirs = discover.discover_page(db, other, "both", "en", LibraryFilters())
assert ([c["id"] for c in mine["continue_watching"]], mine["recently_watched"]) == ([], [])
assert [c["id"] for c in theirs["continue_watching"]] == ["going"]
assert [c["id"] for c in theirs["recently_watched"]] == ["done"]
drawn = discover.surprise(db, me, "movie", "en", 6, [], [], LibraryFilters())
assert {c["id"] for c in drawn["items"]} == {"going", "done"} # their watch is not mine
drawn = discover.surprise(db, other, "movie", "en", 6, [], [], LibraryFilters())
assert {c["id"] for c in drawn["items"]} == {"going"}
def test_only_the_owners_own_link_pushes_their_watch_state(db, make_user):
"""A push is scheduled per user: another account's enabled link must not make this user's
marks reach Plex."""
from app.plex import watch_sync
owner, other = make_user(), make_user()
link = service.set_watch_link(db, owner, True)
link.initial_import_done = True
db.commit()
assert watch_sync.link_for_push(db, owner.id).id == link.id
assert watch_sync.link_for_push(db, other.id) is None