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.
113 lines
4.3 KiB
Python
113 lines
4.3 KiB
Python
"""The playback-session route's mode routing (E6 S1b).
|
|
|
|
`POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native
|
|
file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a
|
|
`playable="transcode"` item (non-h264: HEVC/VP9/AV1/…) must take that HLS path with the video
|
|
RE-ENCODED, not stream-copied. That routing replaced a former `HTTP 501`, and the only thing that
|
|
tells `start_session` to re-encode is the `transcode=` flag this route derives from `it.playable`.
|
|
The command-builder itself is unit-tested in `test_plex_stream.py`; here we pin the ROUTE decision
|
|
(no ffmpeg spawned — `start_session` is stubbed to echo the flag it was handed).
|
|
|
|
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.state as state
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.main import app
|
|
from app.models import PlexItem, PlexLibrary, User
|
|
from app.plex import paths as plex_paths
|
|
from app.plex import stream as plex_stream
|
|
|
|
|
|
def _item(db, *, playable, rating_key="m1"):
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
it = PlexItem(
|
|
rating_key=rating_key,
|
|
library_id=lib.id,
|
|
kind="movie",
|
|
title="A Movie",
|
|
file_path="/media/a.mkv",
|
|
playable=playable,
|
|
)
|
|
db.add(it)
|
|
db.commit()
|
|
return it
|
|
|
|
|
|
def _user(db, email="play@example.com"):
|
|
u = User(email=email)
|
|
db.add(u)
|
|
db.flush()
|
|
return u
|
|
|
|
|
|
def _post(db, user, monkeypatch, rating_key, *, captured):
|
|
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
|
|
was called with into `captured` and returns a stand-in session echoing it back."""
|
|
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
|
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
|
|
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
|
|
|
def fake_start(db_, it, user_id, start, audio, aoff, multi, transcode=False):
|
|
captured["transcode"] = transcode
|
|
return SimpleNamespace(
|
|
id="sid",
|
|
media_start_s=start,
|
|
entry="index.m3u8",
|
|
transcode=transcode,
|
|
hw=False,
|
|
tonemap=False,
|
|
)
|
|
|
|
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
app.dependency_overrides[current_user] = lambda: user
|
|
try:
|
|
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
|
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
|
it = _item(db, playable="transcode")
|
|
user = _user(db)
|
|
captured: dict = {}
|
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["mode"] == "hls"
|
|
assert body["transcode"] is True
|
|
assert captured["transcode"] is True # start_session was told to re-encode
|
|
|
|
|
|
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
|
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
|
it = _item(db, playable="remux", rating_key="m2")
|
|
user = _user(db, "remux@example.com")
|
|
captured: dict = {}
|
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["mode"] == "hls"
|
|
assert body["transcode"] is False
|
|
assert captured["transcode"] is False
|
|
|
|
|
|
def test_direct_item_never_opens_a_session(db, monkeypatch):
|
|
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
|
it = _item(db, playable="direct", rating_key="m3")
|
|
user = _user(db, "direct@example.com")
|
|
captured: dict = {}
|
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["mode"] == "direct"
|
|
assert "transcode" not in captured # start_session not called
|