A season marked watched, a pull from Plex and a YouTube resume point made their first row by a read then an add, and failed on the unique key when a first checkpoint or the watched mark made it in between. They go through the same insert-then-read as the checkpoint (the resume point is one upsert); a pull that meets a local change still waiting for its push leaves it. Folding duplicate Watch later lists moved their videos but not a Home pin, which the cascade then deleted; the first pin of a dropped copy moves onto the kept list. Marking one notice read trimmed before its own flag was flushed, so the trim left one past the cap. The route test now crosses the cap and reads the result from a session of its own.
453 lines
18 KiB
Python
453 lines
18 KiB
Python
"""Plex's service and queries below the HTTP layer, one use case each.
|
|
|
|
The routes are thin callers now, and their own tests stay the contract a client sees. These hold
|
|
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, a finish scrobbles once, an unwatch keeps a hidden mark, a playlist add is
|
|
idempotent). DB-backed (real Postgres).
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.plex import library, queries, rollup, service
|
|
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
|
|
from app.plex.query import LibraryFilters
|
|
|
|
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
|
|
|
|
|
def _lib(db, key="1", kind="movie", **kw):
|
|
lib = PlexLibrary(plex_key=key, title=kw.pop("title", f"L{key}"), kind=kind, enabled=True, **kw)
|
|
db.add(lib)
|
|
db.commit()
|
|
return lib
|
|
|
|
|
|
def _movie(db, lib, rk, **kw):
|
|
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=kw.pop("title", rk), **kw)
|
|
db.add(it)
|
|
db.commit()
|
|
return it
|
|
|
|
|
|
def _state(db, user, it, **kw):
|
|
st = PlexState(user_id=user.id, item_id=it.id, **kw)
|
|
db.add(st)
|
|
db.commit()
|
|
return st
|
|
|
|
|
|
def _uncommitted(db):
|
|
return bool(db.new or db.dirty or db.deleted) or db.in_transaction()
|
|
|
|
|
|
def _page(db, user, **kw):
|
|
args = dict(
|
|
scope="both",
|
|
q=None,
|
|
field="all",
|
|
sort="title",
|
|
sort_dir="asc",
|
|
show="all",
|
|
lang="en",
|
|
offset=0,
|
|
limit=40,
|
|
f=LibraryFilters(),
|
|
)
|
|
args.update(kw)
|
|
return library.library_page(db, user, **args)
|
|
|
|
|
|
# --- whose a watch state is ----------------------------------------------------------------
|
|
|
|
|
|
def test_a_users_watch_state_is_theirs_alone(db, make_user):
|
|
owner, stranger = make_user(), make_user()
|
|
lib = _lib(db)
|
|
it = _movie(db, lib, "m1")
|
|
_state(db, owner, it, status="watched")
|
|
assert queries.state(db, owner, it.id).status == "watched"
|
|
assert queries.state(db, stranger, it.id) is None
|
|
assert rollup.item_state_map(db, stranger, [it.id]) == {}
|
|
assert set(rollup.item_state_map(db, owner, [it.id])) == {it.id}
|
|
assert rollup.item_state_map(db, owner, []) == {}
|
|
|
|
|
|
def test_the_grid_shows_the_callers_state_and_hides_only_what_they_hid(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
lib = _lib(db)
|
|
seen = _movie(db, lib, "seen")
|
|
hid = _movie(db, lib, "hid")
|
|
_state(db, other, seen, status="watched")
|
|
_state(db, other, hid, status="hidden")
|
|
_state(db, me, seen, status="new", position_seconds=90)
|
|
total, rows, _ = _page(db, me)
|
|
assert total == 2
|
|
assert {r.rk: (r.status, r.position_seconds) for r in rows}["seen"][1] == 90
|
|
assert [r.rk for r in _page(db, me, show="watched")[1]] == []
|
|
assert [r.rk for r in _page(db, other, show="watched")[1]] == ["seen"]
|
|
assert [r.rk for r in _page(db, other)[1]] == ["seen"] # their hidden one is gone, for them
|
|
|
|
|
|
def test_the_facets_count_what_the_caller_would_see(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
lib = _lib(db)
|
|
_movie(db, lib, "a", genres=["Drama"], year=2001)
|
|
hid = _movie(db, lib, "b", genres=["Horror"], year=1990)
|
|
_state(db, other, hid, status="hidden")
|
|
mine = library.facets(db, me, scope="both", show="all", lang="en", f=LibraryFilters())
|
|
theirs = library.facets(db, other, scope="both", show="all", lang="en", f=LibraryFilters())
|
|
assert [g["value"] for g in mine["genres"]] == ["Drama", "Horror"]
|
|
assert [g["value"] for g in theirs["genres"]] == ["Drama"]
|
|
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
|
|
|
|
|
|
def test_an_episode_search_joins_the_callers_state(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
lib = _lib(db, "2", kind="show")
|
|
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
|
|
db.add(sh)
|
|
db.commit()
|
|
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=sh.id)
|
|
db.add(ep)
|
|
db.commit()
|
|
_state(db, other, ep, status="watched")
|
|
_, _, eps = _page(db, me, q="pilot")
|
|
assert [(e.rating_key, s, show.rating_key) for e, s, show in eps] == [("e1", None, "s1")]
|
|
|
|
|
|
def test_a_shows_rollup_is_the_callers(db, make_user):
|
|
me, other = make_user(), make_user()
|
|
lib = _lib(db, "2", kind="show")
|
|
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
|
|
db.add(sh)
|
|
db.commit()
|
|
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="E", show_id=sh.id)
|
|
db.add(ep)
|
|
db.commit()
|
|
_state(db, other, ep, status="watched", watched_at=NOW)
|
|
(row,) = queries.show_rollups(db, me.id, [sh.id])
|
|
assert (row.total, row.watched, row.resumed_at) == (1, 0, None)
|
|
assert queries.show_rollups(db, me.id, []) == []
|
|
|
|
|
|
# --- libraries, people, a show ---------------------------------------------------------------
|
|
|
|
|
|
def test_the_browsable_libraries_leave_an_overlay_out_and_count_their_titles(db):
|
|
en = _lib(db, "1", title="Movies")
|
|
_lib(db, "9", title="Filmek", overlay_of=en.id)
|
|
_movie(db, en, "m1")
|
|
_movie(db, en, "m2")
|
|
db.add(PlexItem(rating_key="x", library_id=en.id, kind="episode", title="not a movie"))
|
|
db.commit()
|
|
assert [(lib.plex_key, n) for lib, n in queries.browsable_libraries(db, "en")] == [("1", 2)]
|
|
|
|
|
|
def test_a_filmography_is_cast_or_directors_and_movies_only(db):
|
|
lib = _lib(db)
|
|
_movie(db, lib, "acted", cast_names=["Ann"], year=2000)
|
|
_movie(db, lib, "directed", directors=["Ann"], year=2010)
|
|
_movie(db, lib, "other", cast_names=["Bob"])
|
|
db.add(
|
|
PlexItem(rating_key="ep", library_id=lib.id, kind="episode", title="E", cast_names=["Ann"])
|
|
)
|
|
db.commit()
|
|
rows = queries.filmography(db, "movie", "Ann", [lib.id], "en", limit=10)
|
|
assert [r.rating_key for r in rows] == ["directed", "acted"]
|
|
assert queries.filmography(db, "show", "Ann", [lib.id], "en", limit=10) == []
|
|
assert queries.cast_count(db, "Ann", [lib.id], []) == 1 # the director credit is not cast
|
|
assert queries.cast_count(db, "Ann", [], []) == 0
|
|
|
|
|
|
def test_a_shows_episodes_and_their_keys_are_in_season_order(db):
|
|
lib = _lib(db, "2", kind="show")
|
|
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
|
|
db.add(sh)
|
|
db.commit()
|
|
for rk, season, ep in (("b", 2, 1), ("a", 1, 2), ("c", 1, 1)):
|
|
db.add(
|
|
PlexItem(
|
|
rating_key=rk,
|
|
library_id=lib.id,
|
|
kind="episode",
|
|
title=rk,
|
|
show_id=sh.id,
|
|
season_number=season,
|
|
episode_number=ep,
|
|
)
|
|
)
|
|
db.commit()
|
|
assert [e.rating_key for e in queries.episodes(db, sh)] == ["c", "a", "b"]
|
|
assert queries.episode_keys(db, sh.id) == ["c", "a", "b"]
|
|
assert queries.show(db, "s1\x00") is None # a NUL names no row
|
|
|
|
|
|
# --- collections -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_collection_list_narrows_by_library_name_and_keys(db):
|
|
lib = _lib(db)
|
|
off = _lib(db, "3", title="Off")
|
|
off.enabled = False
|
|
db.add_all(
|
|
[
|
|
PlexCollection(rating_key="c1", library_id=lib.id, title="Bond 100%", child_count=5),
|
|
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=9),
|
|
PlexCollection(rating_key="c3", library_id=off.id, title="Hidden"),
|
|
]
|
|
)
|
|
db.commit()
|
|
keys = lambda rows: [c.rating_key for c in rows] # noqa: E731
|
|
assert keys(queries.collections_page(db, None, None, None, "en")) == ["c2", "c1"]
|
|
assert keys(queries.collections_page(db, "3", None, None, "en")) == ["c3"]
|
|
assert keys(queries.collections_page(db, "404", None, None, "en")) == []
|
|
assert keys(queries.collections_page(db, None, "0%", None, "en")) == ["c1"] # % is literal
|
|
assert keys(queries.collections_page(db, None, "%", None, "en")) == ["c1"]
|
|
assert keys(queries.collections_page(db, None, None, ["c1"], "en")) == ["c1"]
|
|
assert keys(queries.collections_page(db, None, None, [], "en")) == [] # asks for none
|
|
assert queries.collection_named(db, lib.id, "PIXAR").rating_key == "c2"
|
|
assert queries.collection_named(db, off.id, "pixar") is None
|
|
|
|
|
|
def test_a_strip_lists_one_librarys_members_of_a_collection(db):
|
|
lib, other = _lib(db), _lib(db, "4")
|
|
_movie(db, lib, "in", collection_keys=["c1"], year=2002)
|
|
_movie(db, lib, "early", collection_keys=["c1"], year=1999)
|
|
_movie(db, other, "elsewhere", collection_keys=["c1"])
|
|
_movie(db, lib, "out", collection_keys=["c2"])
|
|
rows = queries.library_movies_in(db, lib.id, "c1", "en", limit=10)
|
|
assert [r.rating_key for r in rows] == ["early", "in"]
|
|
movies, shows = queries.collection_members(db, "c1", "en")
|
|
assert ([m.rating_key for m in movies], shows) == (["early", "in", "elsewhere"], [])
|
|
|
|
|
|
# --- writes ----------------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_movie_enrichment_is_stamped_and_left_to_the_caller_to_commit(db):
|
|
lib = _lib(db)
|
|
it = _movie(db, lib, "m1")
|
|
meta = {"Role": [{"tag": "Ann", "role": "Lead"}], "tagline": "Big."}
|
|
service.enrich_movie(db, it, meta)
|
|
assert it.people_enriched_at is not None
|
|
assert "Ann" in (it.cast_names or [])
|
|
assert _uncommitted(db)
|
|
db.rollback()
|
|
db.expire_all()
|
|
assert db.get(PlexItem, it.id).people_enriched_at is None
|
|
|
|
|
|
def test_a_collection_kept_after_a_failed_read_is_editable_and_holds_its_seed(db):
|
|
lib = _lib(db)
|
|
seed = _movie(db, lib, "seed", collection_keys=["old"])
|
|
service.keep_created_collection(db, lib, "new", "Mine", "seed")
|
|
service.keep_created_collection(db, lib, "new2", "Mine 2", "seed")
|
|
db.commit()
|
|
db.expire_all()
|
|
col = queries.collection(db, "new")
|
|
assert (col.editable, col.child_count, col.title) == (True, 1, "Mine")
|
|
assert db.get(PlexItem, seed.id).collection_keys == ["old", "new", "new2"]
|
|
|
|
|
|
def test_marking_a_collection_editable_is_left_to_the_caller(db):
|
|
lib = _lib(db)
|
|
db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="C"))
|
|
db.commit()
|
|
col = queries.collection(db, "c1")
|
|
service.set_collection_editable(col, True)
|
|
assert _uncommitted(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
|
|
|
|
|
|
def test_a_first_write_that_lost_the_race_updates_the_winners_row(db, make_user):
|
|
"""The first checkpoint of an item and the `final` flush beside it both read "no state"; the one
|
|
that writes second must land on the row the first made, not fail on `uq_plex_user_item`."""
|
|
user = make_user()
|
|
it = _movie(db, _lib(db), "m-race")
|
|
_state(db, user, it, position_seconds=10) # the request that won, committed
|
|
st = service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
|
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
|
st = service.set_status(db, user, it.id, None, "hidden")
|
|
assert st.status == "hidden"
|
|
db.commit()
|
|
rows = db.scalars(select(PlexState).where(PlexState.item_id == it.id)).all()
|
|
assert [(r.position_seconds, r.status) for r in rows] == [(42, "hidden")]
|
|
|
|
|
|
def test_a_season_marked_beside_a_first_checkpoint_updates_its_row(db, make_user, monkeypatch):
|
|
"""Marking a season reads every episode's state once; a first checkpoint that made one row since
|
|
must be updated, not collided with on `uq_plex_user_item`."""
|
|
user = make_user()
|
|
lib = _lib(db)
|
|
ep = _movie(db, lib, "e-race")
|
|
_state(db, user, ep, position_seconds=10) # the checkpoint that won, committed
|
|
monkeypatch.setattr(rollup, "item_state_map", lambda *a, **k: {}) # read before it committed
|
|
service.bulk_state(db, user.id, [ep], watched=True)
|
|
db.commit()
|
|
rows = db.scalars(select(PlexState).where(PlexState.item_id == ep.id)).all()
|
|
assert [(r.status, r.position_seconds) for r in rows] == [("watched", 0)]
|