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
+7 -4
View File
@@ -14,6 +14,7 @@ from sqlalchemy import select
from app.db import SessionLocal
from app.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex import service as plex_service
from app.routes.plex import playback
@@ -36,10 +37,12 @@ def test_two_racing_finishes_flip_the_row_once(db, make_user):
first, second = SessionLocal(), SessionLocal()
try:
assert playback._mark_finished(first, user.id, item.id, now) is True # holds the row lock
assert (
plex_service.mark_finished(first, user.id, item.id, now) is True
) # holds the row lock
out: list[bool] = []
racer = threading.Thread(
target=lambda: out.append(playback._mark_finished(second, user.id, item.id, now))
target=lambda: out.append(plex_service.mark_finished(second, user.id, item.id, now))
)
racer.start()
racer.join(0.5)
@@ -57,9 +60,9 @@ def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
user = make_user()
item = _item(db)
now = datetime.now(timezone.utc)
assert playback._mark_finished(db, user.id, item.id, now) is True
assert plex_service.mark_finished(db, user.id, item.id, now) is True
db.commit()
assert playback._mark_finished(db, user.id, item.id, now + timedelta(seconds=10)) is False
assert plex_service.mark_finished(db, user.id, item.id, now + timedelta(seconds=10)) is False
db.commit()
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
assert (st.status, st.position_seconds) == ("watched", 0)
+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
+33 -9
View File
@@ -109,12 +109,7 @@ UNIT_ROUTES = {
# What each HTTP file still does, by file: the debt a unit's sprint pays off. Exact counts. A core
# route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches.
ALLOWED = {
"plex/admin.py": 12,
"plex/discover.py": 44,
"plex/playback.py": 42,
"plex/playlists.py": 80,
}
ALLOWED: dict[str, int] = {}
# The session methods that read or write data. `commit` and `rollback` stay with the handler.
SESSION_DATA = {
@@ -342,11 +337,19 @@ UNIT_PACKAGES = {
"sync/subscriptions.py",
"sync/videos.py",
),
# The Plex mirror: its sync, the watch-state sync with a linked account, TMDB enrichment, the
# pages' reads and writes.
"plex": ("plex/",),
}
# The units fenced so far; the rest join with their sprint.
FENCED = {"capture", "reader", "catalog"}
FENCED = {"capture", "reader", "catalog", "plex"}
# Whose tables a unit's own code may READ. Core code reads a fenced unit's tables through its queries.
READS = {"capture": {"catalog", "plex"}, "reader": {"catalog", "plex"}, "catalog": set()}
READS = {
"capture": {"catalog", "plex"},
"reader": {"catalog", "plex"},
"catalog": set(),
"plex": set(),
}
# The columns that say whose a row is.
OWNER_COLUMNS = {"user_id", "to_user_id", "from_user_id", "created_by"}
# Where a fenced unit's code may commit: the jobs that own their session (the worker and what it
@@ -399,6 +402,26 @@ UNIT_COMMITS = {
"sync/videos.py:run_shorts_classification",
"sync/videos.py:ensure_videos",
},
# The Plex jobs commit per step too: a library sync per section and per enrich/probe batch, the
# TMDB enrichment per batch, the watch sync per pull and push. The pushes and a person's bio
# fetch run as background tasks after the response, on a session of their own; the owner's
# first import runs after the handler committed the link it reads.
"plex": {
"plex/people.py:prune_orphan_people",
"plex/sync.py:sync",
"plex/sync.py:_probe_media_batch",
"plex/sync.py:_enrich_people_batch",
"plex/tmdb.py:enrich_batch",
"plex/tmdb.py:backfill_country_codes",
"plex/tmdb.py:enrich_person",
"plex/tmdb.py:enrich_people_batch",
"plex/watch_sync.py:import_owner_watch_state",
"plex/watch_sync.py:push_state_to_plex",
"plex/watch_sync.py:push_bulk_state_to_plex",
"plex/watch_sync.py:_resolve_account_id",
"plex/watch_sync.py:run_plex_watch_sync",
"plex/watch_sync.py:run_plex_watch_reconcile",
},
}
SQL_WRITES = {"insert", "update", "delete", "pg_insert"}
@@ -533,7 +556,8 @@ PACKAGE_CASES = {
("downloads/x.py", "from app.models import Video\nupdate(Video)"): 1,
("downloads/x.py", "from app.downloads.models import DownloadJob\nDownloadJob()"): 0,
("search.py", "from app.downloads.models import DownloadJob"): 1, # core reads capture
("search.py", "from app.models import PlexItem\nPlexItem()"): 0, # neither side fenced yet
("search.py", "from app.plex.models import PlexItem\nPlexItem()"): 2, # core reads, writes plex
("plex/x.py", "from app.catalog.models import Video"): 1, # no plex→catalog edge
("search.py", "from app.models import Video\nVideo()"): 2, # core reads and writes catalog
("sync/videos.py", "from app.models import Playlist"): 1, # no catalog→reader edge
("downloads/x.py", "q = DownloadJob.user_id == 1"): 1,