HLS sessions were keyed by the item, so a second account opening the same film killed the first one's session; the displaced player reclaimed at its playhead and evicted the other in turn. The registry is now keyed by (viewer, rating_key), segment requests resolve by both, and the session directory carries the viewer (the orphan sweep still recognises the older shapes). Only transcodes counted toward a cap, so remuxes — each a whole segmented copy of the film on the scratch volume — were bounded by the library size. Three bounds now apply after every start, each dropping the least recently used: two sessions per account, the transcode cap, and a new admin knob, plex_max_sessions (default 8), over every session. A superseded session request returns at once, since it waits on the killed ffmpeg, so an account cannot park more request workers than it may hold sessions. The subtitle extractor's full-pass slots were global and minutes long each; one account opening subtitles on film after film could hold them all. An account may now hold three of the six, and still gets windows past that.
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""The near-end checkpoint that finishes a Plex item.
|
|
|
|
- Exactly one scrobble per finish, even when two near-end checkpoints race (the 10 s tick and the
|
|
debounced `seeked` flush arrive together): the transition is decided by a conditional write, not a
|
|
read taken before it.
|
|
- The finish supersedes every resume push still in flight, so none can land at Plex after the
|
|
scrobble and revive a resume point on a watched item.
|
|
"""
|
|
|
|
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.db import SessionLocal
|
|
from app.models import PlexItem, PlexLibrary, PlexState
|
|
from app.routes.plex import playback
|
|
|
|
|
|
def _item(db) -> PlexItem:
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
|
|
db.add(item)
|
|
db.commit()
|
|
return item
|
|
|
|
|
|
def test_two_racing_finishes_flip_the_row_once(db, make_user):
|
|
user = make_user()
|
|
item = _item(db)
|
|
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=5000))
|
|
db.commit()
|
|
now = datetime.now(timezone.utc)
|
|
|
|
first, second = SessionLocal(), SessionLocal()
|
|
try:
|
|
assert playback._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))
|
|
)
|
|
racer.start()
|
|
racer.join(0.5)
|
|
assert racer.is_alive(), "the second writer must wait on the first one's row lock"
|
|
first.commit()
|
|
racer.join(5)
|
|
second.commit()
|
|
finally:
|
|
first.close()
|
|
second.close()
|
|
assert out == [False], "the second checkpoint saw the committed finish: no second scrobble"
|
|
|
|
|
|
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
|
|
db.commit()
|
|
assert playback._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)
|
|
|
|
|
|
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
|
|
user = make_user()
|
|
item = _item(db)
|
|
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
|
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
|
# A resume push was scheduled a moment ago and has not reached Plex yet.
|
|
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
|
|
|
|
r = as_user(user).post(
|
|
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
|
|
)
|
|
assert r.json() == {"status": "watched", "position_seconds": 0}
|
|
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|