The 10 s tick in flight when the player finishes (or a refusal rollback whose state write in fact committed) wrote its mid-item position onto the freshly watched row and scheduled a resume push after the scrobble, so Plex showed the item in progress again. The checkpoint is now a conditional write that skips a row marked watched in the last 30 s and answers like a finish; a rewatch past that is checkpointed as before. Marking an item (or a season/show) watched also supersedes resume pushes still in flight, as the near-end finish already did.
455 lines
19 KiB
Python
455 lines
19 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")
|
|
assert service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
|
|
st = queries.state(db, user, bare.id)
|
|
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
|
|
assert service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
|
st = queries.state(db, user, it.id)
|
|
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)]
|