fix: a first watch state and a first Watch later survive two requests at once

The first checkpoint of an item and the final flush beside it both read no
state and both inserted; the loser failed on uq_plex_user_item. Two first saves
each made a Watch later list. Both are created by an ON CONFLICT DO NOTHING
insert and then read; a partial unique index holds one Watch later per user, and
the migration folds an existing second list into the first. Plex keeps one
per-item state map (rollup.item_state_map).
This commit is contained in:
2026-10-20 00:45:22 +02:00
parent b376373af5
commit 3a5212cd8a
11 changed files with 215 additions and 40 deletions
+68
View File
@@ -0,0 +1,68 @@
"""Migration 0112's data step, DB-lane: a user who already has two Watch later lists keeps the oldest,
with the other's videos moved in after its own (one it already held is not added twice), and the
one-per-user index then refuses a second list.
The schema half is checked by test_migrations.py; a video lost on the way is invisible there.
"""
from datetime import datetime, timezone
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.catalog.models import Channel, Video
from app.db import engine
def test_a_second_watch_later_is_folded_into_the_first(db, make_user):
user, other = make_user(), make_user()
db.add(Channel(id="UCm", title="M"))
now = datetime.now(timezone.utc)
for v in ("v1", "v2", "v3"):
db.add(Video(id=v, channel_id="UCm", published_at=now, duration_seconds=60))
db.commit()
cfg = Config("alembic.ini")
command.downgrade(cfg, "0111_audit_request_id")
try:
with engine.begin() as conn:
ids = conn.execute(
text(
"INSERT INTO playlists (user_id, name, kind, position) VALUES"
" (:u, 'Watch later', 'watch_later', 0), (:u, 'Watch later', 'watch_later', 1),"
" (:o, 'Watch later', 'watch_later', 0) RETURNING id"
),
{"u": user.id, "o": other.id},
).scalars()
first, second, theirs = list(ids)
conn.execute(
text(
"INSERT INTO playlist_items (playlist_id, video_id, position) VALUES"
" (:a, 'v1', 0), (:b, 'v2', 0), (:b, 'v1', 1), (:c, 'v3', 0)"
),
{"a": first, "b": second, "c": theirs},
)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
lists = conn.execute(
text("SELECT id, user_id FROM playlists WHERE kind = 'watch_later' ORDER BY id")
).all()
assert [tuple(r) for r in lists] == [(first, user.id), (theirs, other.id)]
items = conn.execute(
text(
"SELECT playlist_id, video_id, position FROM playlist_items ORDER BY playlist_id, position"
)
).all()
assert [tuple(r) for r in items] == [(first, "v1", 0), (first, "v2", 1), (theirs, "v3", 0)]
with pytest.raises(IntegrityError), engine.begin() as conn:
conn.execute(
text(
"INSERT INTO playlists (user_id, name, kind, position)"
" VALUES (:u, 'Watch later', 'watch_later', 2)"
),
{"u": user.id},
)
+19 -4
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
from sqlalchemy import select
from app.plex import library, queries, service
from app.plex import library, queries, rollup, service
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import LibraryFilters
@@ -71,9 +71,9 @@ def test_a_users_watch_state_is_theirs_alone(db, make_user):
_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 queries.states(db, stranger, [it.id]) == {}
assert set(queries.states(db, owner, [it.id])) == {it.id}
assert queries.states(db, owner, []) == {}
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):
@@ -421,3 +421,18 @@ def test_only_the_owners_own_link_pushes_their_watch_state(db, make_user):
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")]
+18
View File
@@ -308,3 +308,21 @@ def test_a_pin_is_one_per_thing_and_only_its_owners(db, make_user):
pins.remove(db, pins.own_pin(db, user, pin.id))
db.commit()
assert db.execute(select(ComfortPin)).first() is None
def test_a_watch_later_save_that_lost_the_race_uses_the_winners_list(db, make_user, monkeypatch):
"""The first two saves at once both read "no Watch later"; the second must land in the list the
first made, not make one of its own."""
user = make_user()
won = service.watch_later(db, user)
db.commit()
real = queries.watch_later
stale = [True] # this request's first read happened before the winner committed
def read(d, u):
return None if stale and stale.pop() else real(d, u)
monkeypatch.setattr(queries, "watch_later", read)
assert service.watch_later(db, user).id == won.id
db.commit()
assert [p.id for p in queries.playlists_for(db, user) if p.kind == "watch_later"] == [won.id]