fix(plex): playback sessions per viewer, bounded in total; a fair share of subtitle passes

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.
This commit is contained in:
2026-09-19 07:16:17 +02:00
parent 6f9ba428d5
commit dc4e8b9fcd
13 changed files with 278 additions and 102 deletions
+4
View File
@@ -315,6 +315,10 @@ class Settings(BaseSettings):
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve # re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
# and remux (stream-copy) are not capped by this. # and remux (stream-copy) are not capped by this.
plex_max_transcodes: int = 4 plex_max_transcodes: int = 4
# Max concurrent playback sessions of ANY kind — remux (stream-copy) included. Cheap on the CPU but
# each one is a whole segmented copy of the film on the scratch volume, written faster than
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
plex_max_sessions: int = 8
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- # --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render # source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
+77 -39
View File
@@ -60,8 +60,10 @@ _WARMUP_SEG_TIMEOUT_S = 8.0
# its index for that one. See `segment_name` for why this is a constant and not a comment. # its index for that one. See `segment_name` for why this is a constant and not a comment.
_VIDEO_VARIANT = "0" _VIDEO_VARIANT = "0"
_SESSION_IDLE_S = 600 # reap a session with no access for this long _SESSION_IDLE_S = 600 # reap a session with no access for this long
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session); # A session directory is named `{rating_key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}` (see
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names # start_session); before per-viewer sessions it was `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}`,
# and before multi-audio just `{rating_key}_{int(start_s)}` — the older shapes stay sweepable, since a
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may # matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory # legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
# under the root" would take the neighbours with it. # under the root" would take the neighbours with it.
@@ -72,7 +74,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to # non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep # fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
# and someone else's data. # and someone else's data.
_SESSION_DIR_RE = re.compile(r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$") _SESSION_DIR_RE = re.compile(
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
)
# When this process started. `sweep_orphans` only deletes directories older than this: everything # When this process started. `sweep_orphans` only deletes directories older than this: everything
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path # newer belongs to a session THIS run created, and since `start_session` reuses a directory path
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory". # for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
@@ -88,7 +92,16 @@ _PROCESS_START = time.time()
_MTIME_GRACE_S = 2.0 _MTIME_GRACE_S = 2.0
_lock = threading.Lock() _lock = threading.Lock()
_sessions: dict[str, "HlsSession"] = {} # Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
_sessions: dict[tuple[int, str], "HlsSession"] = {}
# How many live sessions one account may hold at once (two devices, or a next item starting while the
# last one winds down). A new one past it replaces that account's least recently used — whose waiting
# session request then returns at once, since `wait_for` watches the killed ffmpeg — so one account
# can neither pile up film-sized scratch copies nor park more than this many request workers.
_MAX_SESSIONS_PER_USER = 2
class HlsSession: class HlsSession:
@@ -102,16 +115,17 @@ class HlsSession:
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
tonemap: bool = False, tonemap: bool = False,
user_id: int = 0,
): ):
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed # Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the # replaces the viewer's registered session for the item — and the old player's next request
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key, # for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it # under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
# (deep-review round 5). A request naming a session that is no longer registered is a 404, # (deep-review round 5). A request naming a session that is no longer registered is a 404,
# which the player recovers from by restarting at the playhead. # which the player recovers from by restarting at the playhead.
self.id = secrets.token_urlsafe(9) self.id = secrets.token_urlsafe(9)
self.key = key self.key = key # the item's rating_key (what the logs name)
self.user_id = user_id # whose session it is: the registry key is (user_id, key)
self.dir = directory self.dir = directory
self.proc = proc self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
@@ -637,29 +651,47 @@ def _kill(s: HlsSession) -> None:
shutil.rmtree(s.dir, ignore_errors=True) shutil.rmtree(s.dir, ignore_errors=True)
def _enforce_cap(cap: int) -> None: def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux """Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py: least recently accessed sessions over it:
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
# transcode sessions over the cap, leaving any concurrent remux streams untouched. * this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
tx = [(key, s) for key, s in _sessions.items() if s.transcode] box evicts itself before anyone else;
if len(tx) <= cap: * TRANSCODE sessions (plex_max_transcodes): the CPU/GPU-heavy ones;
* EVERY session (plex_max_sessions). A remux is cheap on the CPU but not on the disk: each one is a
whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
rebuffering" has no other explanation anywhere, hence the line."""
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
if len(pool) <= cap:
return return
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]: for reg, sess in sorted(pool, key=lambda kv: kv[1].last_access)[: len(pool) - cap]:
# Somebody's playback stops here. The route turns the missing session into a retryable
# 404 and the player restarts at the playhead, so from the server nothing failed — and
# "my film keeps rebuffering" has no other explanation anywhere.
log.info( log.info(
"a transcode session was dropped to stay under the cap", "a session was dropped to stay under a cap",
extra=logs.event("plex.stream.session_culled", key=key, cap=cap, sessions=len(tx)), extra=logs.event(
"plex.stream.session_culled",
key=sess.key,
bound=bound,
cap=cap,
sessions=len(pool),
),
) )
_kill(s) _kill(sess)
_sessions.pop(key, None) _sessions.pop(reg, None)
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
cull(list(_sessions.items()), max_sessions, "total")
def start_session( def start_session(
db: DbSession, db: DbSession,
item: PlexItem, item: PlexItem,
user_id: int,
start_s: float, start_s: float,
audio_ord: int | None = None, audio_ord: int | None = None,
aoff: float = 0.0, aoff: float = 0.0,
@@ -679,6 +711,7 @@ def start_session(
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change. # session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm") loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
key = item.rating_key key = item.rating_key
reg = (user_id, key) # the registry key: one session per viewer per item
start_s = max(0.0, float(start_s)) start_s = max(0.0, float(start_s))
audio_langs = _probe_audio_langs(src) if multi else [] audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
@@ -691,7 +724,7 @@ def start_session(
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that # the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment). # regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "") tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" directory = _HLS_ROOT / f"{key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}"
def _seg(i: int) -> Path: def _seg(i: int) -> Path:
return directory / segment_name(i, _VIDEO_VARIANT if variants else None) return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
@@ -717,9 +750,9 @@ def start_session(
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted. # platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock: with _lock:
if replace_only is not None and _sessions.get(key) is not replace_only: if replace_only is not None and _sessions.get(reg) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(key, None) old = _sessions.pop(reg, None)
if old is not None: if old is not None:
_kill(old) _kill(old)
shutil.rmtree(directory, ignore_errors=True) shutil.rmtree(directory, ignore_errors=True)
@@ -753,8 +786,9 @@ def start_session(
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap, tonemap=tonemap,
user_id=user_id,
) )
_sessions[key] = s _sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream # The one line that says which pipeline a session actually got. Everything downstream
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until # — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
# now it was only inferable from the ffmpeg command nobody keeps. # now it was only inferable from the ffmpeg command nobody keeps.
@@ -770,9 +804,13 @@ def start_session(
start_s=round(start_s, 1), start_s=round(start_s, 1),
), ),
) )
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# at least 1 so playback can't be capped to zero. # capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) _enforce_caps(
user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")),
)
return s return s
s = _spawn(*_select_encode(db, item, transcode, src)) s = _spawn(*_select_encode(db, item, transcode, src))
@@ -798,7 +836,7 @@ def start_session(
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that # turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" / # exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests. # "Not available yet") the client re-requests.
return current_session(key) or s return current_session(user_id, key) or s
s = respawned s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready: if ready:
@@ -829,9 +867,9 @@ def start_session(
return s return s
def current_session(key: str) -> HlsSession | None: def current_session(user_id: int, key: str) -> HlsSession | None:
with _lock: with _lock:
s = _sessions.get(key) s = _sessions.get((user_id, key))
if s is not None: if s is not None:
s.last_access = time.time() s.last_access = time.time()
return s return s
@@ -1060,21 +1098,21 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
now = time.time() now = time.time()
dropped = 0 dropped = 0
with _lock: with _lock:
for key, s in list(_sessions.items()): for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None done = s.proc.poll() is not None
idle = now - s.last_access idle = now - s.last_access
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_kill(s) _kill(s)
_sessions.pop(key, None) _sessions.pop(reg, None)
dropped += 1 dropped += 1
return dropped return dropped
def touch_session(key: str) -> None: def touch_session(user_id: int, key: str) -> None:
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move """Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
viewer's session alive was to keep EVERY abandoned one just as long.""" viewer's session alive was to keep EVERY abandoned one just as long."""
with _lock: with _lock:
s = _sessions.get(key) s = _sessions.get((user_id, key))
if s is not None: if s is not None:
s.last_access = time.time() s.last_access = time.time()
+33 -4
View File
@@ -461,11 +461,18 @@ _FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-ful
# which time something has finished. # which time something has finished.
_MAX_WINDOWS_INFLIGHT = 8 _MAX_WINDOWS_INFLIGHT = 8
_MAX_FULL_INFLIGHT = 6 _MAX_FULL_INFLIGHT = 6
# …of which one account may hold this many. The full-pass slots are minutes each, and a hostile (or
# merely curious) account opening subtitles on film after film could otherwise hold all of them while
# everyone else's tracks never completed. A viewer over their share still gets windows — the part
# that makes a track watchable now — just not another whole-file read until one of theirs ends.
_MAX_FULL_PER_USER = 3
_MAX_TRACK_WINDOWS = 2 _MAX_TRACK_WINDOWS = 2
_LOCK = threading.Lock() _LOCK = threading.Lock()
# Queued or running jobs → the absolute span a window job reads (None for a full pass). # Queued or running jobs → the absolute span a window job reads (None for a full pass).
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {} _INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
# rating_key → the account whose poll queued that item's full pass (see _MAX_FULL_PER_USER).
_FULL_OWNERS: dict[str, int] = {}
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…). # Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
_FAILURES: dict[str, tuple[str, float]] = {} _FAILURES: dict[str, tuple[str, float]] = {}
# rating_key → when its full pass last failed (see FULL_RETRY_S). # rating_key → when its full pass last failed (see FULL_RETRY_S).
@@ -515,6 +522,7 @@ def reset_state() -> None:
global _STOPPING global _STOPPING
with _LOCK: with _LOCK:
_INFLIGHT.clear() _INFLIGHT.clear()
_FULL_OWNERS.clear()
_FAILURES.clear() _FAILURES.clear()
_FULL_FAILED.clear() _FULL_FAILED.clear()
_WIN_FAILED.clear() _WIN_FAILED.clear()
@@ -598,7 +606,7 @@ def _window_failed(key: str, at: float) -> bool:
return any(a <= at <= b for a, b, _ in _live_window_failures(key)) return any(a <= at <= b for a, b, _ in _live_window_failures(key))
def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict: def ensure(rating_key: str, sub_ord: int, at: float = 0.0, viewer: int = 0) -> dict:
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks. """State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing`` ``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
@@ -641,7 +649,14 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S: if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
probe = edge + 1.0 probe = edge + 1.0
want_window = not _window_failed(key, probe) want_window = not _window_failed(key, probe)
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed) _schedule(
rating_key,
sub_ord,
probe,
want_window=want_window,
want_full=not full_failed,
viewer=viewer,
)
# `growing`: whether the track will fill in on its own. False once the full pass has failed and # `growing`: whether the track will fill in on its own. False once the full pass has failed and
# is backed off — a partial track then stays partial until a window is asked for, so the player # is backed off — a partial track then stays partial until a window is asked for, so the player
# may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film. # may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film.
@@ -667,7 +682,12 @@ def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]
def _schedule( def _schedule(
rating_key: str, sub_ord: int, at: float, want_window: bool, want_full: bool = True rating_key: str,
sub_ord: int,
at: float,
want_window: bool,
want_full: bool = True,
viewer: int = 0,
) -> None: ) -> None:
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one """Kick off the jobs this track still needs: the window that makes it watchable now, and the one
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
@@ -700,8 +720,15 @@ def _schedule(
ident = ("win", rating_key, sub_ord, w_start) ident = ("win", rating_key, sub_ord, w_start)
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S) _INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
jobs.append((ident, w_start)) jobs.append((ident, w_start))
if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT: mine = sum(1 for owner in _FULL_OWNERS.values() if owner == viewer)
if (
want_full
and n_full < _MAX_FULL_INFLIGHT
and ("full", rating_key) not in _INFLIGHT
and mine < _MAX_FULL_PER_USER
):
_INFLIGHT[("full", rating_key)] = None _INFLIGHT[("full", rating_key)] = None
_FULL_OWNERS[rating_key] = viewer
jobs.append((("full", rating_key), None)) jobs.append((("full", rating_key), None))
for ident, window_start in jobs: for ident, window_start in jobs:
pool = _FULL_POOL if window_start is None else _WIN_POOL pool = _FULL_POOL if window_start is None else _WIN_POOL
@@ -765,6 +792,8 @@ def _run(
finally: finally:
with _LOCK: with _LOCK:
_INFLIGHT.pop(ident, None) _INFLIGHT.pop(ident, None)
if ident[0] == "full":
_FULL_OWNERS.pop(rating_key, None)
def _pass_failed( def _pass_failed(
+7 -7
View File
@@ -121,7 +121,7 @@ def subtitle_status(
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first `at` is the viewer's absolute position: the extractor windows around it, so what gets read first
is the part actually about to be watched.""" is the part actually about to be watched."""
return subtitles.ensure(rating_key, sub_ord, at) return subtitles.ensure(rating_key, sub_ord, at, viewer=user.id)
@router.get("/subtitle/{rating_key}/{sub_ord}") @router.get("/subtitle/{rating_key}/{sub_ord}")
@@ -362,7 +362,7 @@ def item_progress(
# finish latch is up, or before the first position is known. Those are the states in which # finish latch is up, or before the first position is known. Those are the states in which
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets # reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
# the same 404 → reclaim the player already handles. # the same 404 → reclaim the player already handles.
plex_stream.touch_session(str(rating_key)) plex_stream.touch_session(user.id, str(rating_key))
position = max(0, int(body.position_seconds)) position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds) duration = int(body.duration_seconds)
final = body.final final = body.final
@@ -607,7 +607,7 @@ def stream_session(
} }
if plex_paths.local_media_path(db, it.file_path) is None: if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode) s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise HTTPException(status_code=404, detail="Media file not found on disk")
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the # Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
@@ -677,9 +677,9 @@ def stream_hls_file(
references its segments/subtitles by relative name, all resolving back here. references its segments/subtitles by relative name, all resolving back here.
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
rating_key alone and a seek-restart or a second viewer of the same item replaces the registered (viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
session that is no longer the registered one is a 404, and the player reclaims at the playhead. session that is no longer the registered one is a 404, and the player reclaims at the playhead.
@@ -695,7 +695,7 @@ def stream_hls_file(
or ".." in filename or ".." in filename
): ):
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
s = plex_stream.current_session(str(rating_key)) s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id: if s is None or s.id != session_id:
raise HTTPException(status_code=404, detail="No active playback session") raise HTTPException(status_code=404, detail="No active playback session")
path = s.dir / filename path = s.dir / filename
+1
View File
@@ -176,6 +176,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"), ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback. # Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
# crf 0–51 (x264 range); max_height 0 = source. # crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"), ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
+8 -8
View File
@@ -116,10 +116,10 @@ def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limi
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions): def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["12345"] = s sessions[(1, "12345")] = s
stream.touch_session("12345") stream.touch_session(1, "12345")
assert stream.reap_idle() == 0 assert stream.reap_idle() == 0
assert "12345" in sessions and s.dir.exists() and not s.proc.killed assert (1, "12345") in sessions and s.dir.exists() and not s.proc.killed
# --- the two call sites, driven through the real routes (DB lane) ------------------------------- # --- the two call sites, driven through the real routes (DB lane) -------------------------------
@@ -157,7 +157,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
db.add(user) db.add(user)
db.commit() db.commit()
s = _Session(tmp_path, "paused", finished=True, idle_s=90) s = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["paused1"] = s sessions[(user.id, "paused1")] = s
try: try:
resp = _client(db, user).post( resp = _client(db, user).post(
f"/api/plex/item/{it.rating_key}/progress", f"/api/plex/item/{it.rating_key}/progress",
@@ -167,7 +167,7 @@ def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch,
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there" assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
assert "paused1" in sessions and not s.proc.killed assert (user.id, "paused1") in sessions and not s.proc.killed
def test_starting_a_session_leaves_another_viewers_paused_one_alone( def test_starting_a_session_leaves_another_viewers_paused_one_alone(
@@ -183,9 +183,9 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
db.add(user) db.add(user)
db.commit() db.commit()
paused = _Session(tmp_path, "paused", finished=True, idle_s=90) paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
sessions["someone-else"] = paused sessions[(999, "someone-else")] = paused
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False): def fake_start(db_, item, user_id, start, audio=None, aoff=0.0, multi=False, transcode=False):
return SimpleNamespace( return SimpleNamespace(
id="sid", id="sid",
media_start_s=start, media_start_s=start,
@@ -201,4 +201,4 @@ def test_starting_a_session_leaves_another_viewers_paused_one_alone(
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert "someone-else" in sessions and not paused.proc.killed assert (999, "someone-else") in sessions and not paused.proc.killed
+1 -1
View File
@@ -68,7 +68,7 @@ def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch): def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
user = make_user() user = make_user()
item = _item(db) item = _item(db)
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda key: None) monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
earlier = datetime.now(timezone.utc) - timedelta(seconds=5) earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
# A resume push was scheduled a moment ago and has not reached Plex yet. # 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) assert playback._should_push_resume(user.id, item.id, earlier, final=True)
+32 -18
View File
@@ -38,6 +38,9 @@ class _Proc:
return 0 return 0
VIEWER = 41 # the id of the account the test client is signed in as
class _Session: class _Session:
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None): def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
self.id = session_id self.id = session_id
@@ -61,7 +64,7 @@ def sessions(monkeypatch):
@pytest.fixture @pytest.fixture
def client(monkeypatch): def client(monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
user = User(email="hls@example.com") user = User(id=VIEWER, email="hls@example.com")
app.dependency_overrides[current_user] = lambda: user app.dependency_overrides[current_user] = lambda: user
try: try:
yield TestClient(app) yield TestClient(app)
@@ -74,12 +77,23 @@ def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, se
d.mkdir() d.mkdir()
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one (d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
(d / "seg_12.ts").write_bytes(b"mine") (d / "seg_12.ts").write_bytes(b"mine")
sessions["m1"] = _Session("sid-a", d) sessions[(VIEWER, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.content == b"mine" assert r.content == b"mine"
def test_another_viewers_session_of_the_same_item_is_not_this_viewers(client, tmp_path, sessions):
"""Sessions are per viewer: a second account playing the same film has its own, and knowing its
id does not make it yours — resolution is by (viewer, item) before the id is compared."""
d = tmp_path / "m1_u7_0_0_+0.00"
d.mkdir()
(d / "index.m3u8").write_bytes(b"#EXTM3U")
sessions[(7, "m1")] = _Session("sid-a", d)
r = client.get("/api/plex/stream/m1/hls/sid-a/index.m3u8")
assert r.status_code == 404, r.text
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes( def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
client, tmp_path, sessions client, tmp_path, sessions
): ):
@@ -89,7 +103,7 @@ def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
theirs.mkdir() theirs.mkdir()
(theirs / "seg_11.ts").write_bytes(b"") (theirs / "seg_11.ts").write_bytes(b"")
(theirs / "seg_12.ts").write_bytes(b"theirs") (theirs / "seg_12.ts").write_bytes(b"theirs")
sessions["m1"] = _Session("sid-b", theirs) sessions[(VIEWER, "m1")] = _Session("sid-b", theirs)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
assert b"theirs" not in r.content assert b"theirs" not in r.content
@@ -105,7 +119,7 @@ def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
monkeypatch.setattr( monkeypatch.setattr(
plex_stream, plex_stream,
"start_session", "start_session",
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0), lambda *a, **k: started.append(a[3] if len(a) > 3 else 0.0),
) )
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
@@ -122,7 +136,7 @@ def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessio
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next (d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -150,7 +164,7 @@ def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
d.mkdir() d.mkdir()
(d / "seg_0_0.ts").write_bytes(b"x") (d / "seg_0_0.ts").write_bytes(b"x")
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
started = time.monotonic() started = time.monotonic()
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}") r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -166,7 +180,7 @@ def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_pa
d.mkdir() d.mkdir()
(d / "seg_a0_0.ts").write_bytes(b"x") (d / "seg_a0_0.ts").write_bytes(b"x")
(d / "seg_a0_1.ts").write_bytes(b"audio") (d / "seg_a0_1.ts").write_bytes(b"audio")
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert r.content == b"audio" assert r.content == b"audio"
@@ -179,7 +193,7 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
its playlists and its segments and nothing else, so any other name is answerable now.""" its playlists and its segments and nothing else, so any other name is answerable now."""
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8") r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -194,7 +208,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets.""" subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets."""
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
sessions["m1"] = _Session("sid-a", d, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=True)
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts") r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
@@ -209,7 +223,7 @@ def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_11.ts").write_bytes(b"") (d / "seg_11.ts").write_bytes(b"")
sessions["m1"] = _Session("sid-a", d) sessions[(VIEWER, "m1")] = _Session("sid-a", d)
# The file existed when the wait checked, and is gone by the time the route reads it. # The file existed when the wait checked, and is gone by the time the route reads it.
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True) monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
@@ -224,7 +238,7 @@ def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
d = tmp_path / "m1_0_0_+0.00" d = tmp_path / "m1_0_0_+0.00"
d.mkdir() d.mkdir()
(d / "seg_5.ts").write_bytes(b"x") (d / "seg_5.ts").write_bytes(b"x")
sessions["m1"] = _Session("sid-a", d, running=False) sessions[(VIEWER, "m1")] = _Session("sid-a", d, running=False)
started = time.monotonic() started = time.monotonic()
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts") r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
elapsed = time.monotonic() - started elapsed = time.monotonic() - started
@@ -294,10 +308,10 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7).""" 30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7)."""
single = tmp_path / "m1_0_0_+0.00" single = tmp_path / "m1_0_0_+0.00"
single.mkdir() single.mkdir()
sessions["m1"] = _Session("sid-a", single, running=True) sessions[(VIEWER, "m1")] = _Session("sid-a", single, running=True)
multi = tmp_path / "m2_0_multi_+0.00" multi = tmp_path / "m2_0_multi_+0.00"
multi.mkdir() multi.mkdir()
sessions["m2"] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"]) sessions[(VIEWER, "m2")] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
# Each of these belongs to the other shape (or to no shape at all) and must be refused without # Each of these belongs to the other shape (or to no shape at all) and must be refused without
# a wait — the assertion is the ELAPSED time as much as the status. # a wait — the assertion is the ELAPSED time as much as the status.
@@ -315,8 +329,8 @@ def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, c
) )
# And the control: the names each session CAN write are still waited for, not refused. # And the control: the names each session CAN write are still waited for, not refused.
assert plex_stream.artifact_may_arrive(sessions["m1"], "index.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "index.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m1")], "seg_0.ts")
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "master.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "stream_a1.m3u8")
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts") assert plex_stream.artifact_may_arrive(sessions[(VIEWER, "m2")], "seg_a1_0.ts")
+1 -1
View File
@@ -55,7 +55,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None) monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv") monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
def fake_start(db_, it, start, audio, aoff, multi, transcode=False): def fake_start(db_, it, user_id, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode captured["transcode"] = transcode
return SimpleNamespace( return SimpleNamespace(
id="sid", id="sid",
+78 -17
View File
@@ -108,7 +108,7 @@ class TestSweepOrphans:
def test_a_live_sessions_directory_is_never_deleted(self, hls_root): def test_a_live_sessions_directory_is_never_deleted(self, hls_root):
# Registered session, but with an OLD directory — only the live check can save it. # Registered session, but with an OLD directory — only the live check can save it.
d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600) d = _session_dir(hls_root, "12345_0_multi_+0.00", age_s=3600)
stream._sessions["k"] = _FakeSession(d) stream._sessions[(1, "k")] = _FakeSession(d)
assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0 assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0
assert d.exists() assert d.exists()
@@ -179,10 +179,20 @@ class _CapturingProc:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
_CapturingProc.calls.append((args, kwargs)) _CapturingProc.calls.append((args, kwargs))
self.killed = False
def poll(self): def poll(self):
return None return None
def terminate(self):
self.killed = True
def wait(self, timeout=None):
return 0
def kill(self):
self.killed = True
def _venc(cmd): def _venc(cmd):
"""The video codec ffmpeg was told to use (the value after the first `-c:v`).""" """The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
@@ -495,7 +505,7 @@ class TestColdStartWarmup:
return True return True
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
# seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS. # seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS.
assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)] assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)]
@@ -508,7 +518,7 @@ class TestColdStartWarmup:
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2 assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2
@@ -522,7 +532,7 @@ class TestColdStartWarmup:
return False return False
monkeypatch.setattr(stream, "wait_for", spy) monkeypatch.setattr(stream, "wait_for", spy)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
assert s is not None assert s is not None
assert waited == ["seg_0.ts"] assert waited == ["seg_0.ts"]
@@ -548,6 +558,7 @@ class TestTranscodeStartSession:
"plex_transcode_crf": 18, "plex_transcode_crf": 18,
"plex_transcode_max_height": 480, "plex_transcode_max_height": 480,
"plex_max_transcodes": 4, "plex_max_transcodes": 4,
"plex_max_sessions": 8,
}[k], }[k],
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -573,7 +584,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (False, False) stream, "_select_encode", lambda db, item, transcode, src: (False, False)
) )
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is False assert s is not None and s.transcode is True and s.hw is False
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -590,7 +601,7 @@ class TestTranscodeStartSession:
stream, "_select_encode", lambda db, item, transcode, src: (True, False) stream, "_select_encode", lambda db, item, transcode, src: (True, False)
) )
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
@@ -609,7 +620,7 @@ class TestTranscodeStartSession:
# tonemap=True on the session, still on the GPU (hw=True) and never niced. # tonemap=True on the session, still on the GPU (hw=True) and never niced.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True)) monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
@@ -635,7 +646,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait) monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert ( assert (
s is not None and s.transcode is True and s.hw is False s is not None and s.transcode is True and s.hw is False
@@ -666,7 +677,7 @@ class TestTranscodeStartSession:
monkeypatch.setattr(stream, "wait_for", flaky_wait) monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
@@ -699,17 +710,17 @@ class TestTranscodeStartSession:
def wait_then_supersede(path, timeout=20.0, proc=None): def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the # Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing. # (now-killed) HW proc produced nothing.
stream._sessions["12345"] = newer stream._sessions[(7, "12345")] = newer
return False return False
monkeypatch.setattr(stream, "wait_for", wait_then_supersede) monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact. # No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1 assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it assert s is newer # yielded the currently-registered session, did not clobber it
assert stream._sessions["12345"] is newer assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing( def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
self, capturing_env, monkeypatch self, capturing_env, monkeypatch
@@ -727,16 +738,16 @@ class TestTranscodeStartSession:
def wait_then_cull(path, timeout=20.0, proc=None): def wait_then_cull(path, timeout=20.0, proc=None):
captured["s"] = stream._sessions[ captured["s"] = stream._sessions[
"12345" (7, "12345")
] # the HW session the initial _spawn just registered ] # the HW session the initial _spawn just registered
del stream._sessions[ del stream._sessions[
"12345" (7, "12345")
] # simulate _enforce_cap dropping the LRU under cap pressure ] # simulate _enforce_cap dropping the LRU under cap pressure
return False return False
monkeypatch.setattr(stream, "wait_for", wait_then_cull) monkeypatch.setattr(stream, "wait_for", wait_then_cull)
s = stream.start_session( s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True db=None, user_id=7, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
) )
assert ( assert (
len(_CapturingProc.calls) == 1 len(_CapturingProc.calls) == 1
@@ -752,7 +763,9 @@ class TestTranscodeStartSession:
monkeypatch.setattr( monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False) stream, "_select_encode", lambda db, item, transcode, src: (False, False)
) )
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False s = stream.start_session(
db=None, user_id=7, item=_FakeItem(), start_s=0.0
) # transcode defaults False
assert s is not None and s.transcode is False assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0] (cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy" assert _venc(cmd) == "copy"
@@ -896,7 +909,7 @@ class TestAudioLoudnorm:
) )
if multi: if multi:
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"]) monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi) s = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0, multi=multi)
assert s is not None assert s is not None
(cmd, *_), _ = _CapturingProc.calls[0] (cmd, *_), _ = _CapturingProc.calls[0]
return s, cmd return s, cmd
@@ -945,3 +958,51 @@ class TestAudioLoudnorm:
# sweep no longer recognises is one that never gets cleaned up. # sweep no longer recognises is one that never gets cleaned up.
s, _ = self._spawn(monkeypatch, on=on) s, _ = self._spawn(monkeypatch, on=on)
assert _SESSION_DIR_RE.match(s.dir.name) assert _SESSION_DIR_RE.match(s.dir.name)
class TestSessionsPerViewer:
"""HLS sessions were keyed by the ITEM, so a second account opening the same film killed the
first one's session; each then reclaimed and evicted the other. And only transcodes counted
toward a cap, so remuxes — each a whole segmented copy of the film on disk — were unbounded."""
capturing_env = TestTranscodeStartSession.capturing_env
@staticmethod
def _start(monkeypatch, user_id, rating_key="12345"):
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
return stream.start_session(
db=None, user_id=user_id, item=_FakeItem(rating_key=rating_key), start_s=0.0
)
def test_two_viewers_of_one_film_each_keep_their_own_session(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
a = self._start(monkeypatch, user_id=1)
b = self._start(monkeypatch, user_id=2)
assert stream._sessions == {(1, "12345"): a, (2, "12345"): b}
assert not a.proc.killed
assert a.dir != b.dir and _SESSION_DIR_RE.match(a.dir.name)
def test_one_account_holds_at_most_two_sessions(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
first = self._start(monkeypatch, user_id=1, rating_key="1")
first.last_access -= 60 # the least recently used of the three
self._start(monkeypatch, user_id=1, rating_key="2")
self._start(monkeypatch, user_id=1, rating_key="3")
assert sorted(stream._sessions) == [(1, "2"), (1, "3")]
assert first.proc.killed
def test_remux_sessions_count_toward_the_overall_ceiling(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_sessions", {})
monkeypatch.setattr(
stream.sysconfig,
"get_int",
lambda db, k: {"plex_max_transcodes": 4, "plex_max_sessions": 3}[k],
)
oldest = self._start(monkeypatch, user_id=1)
oldest.last_access -= 60
for uid in (2, 3, 4):
self._start(monkeypatch, user_id=uid)
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
assert oldest.proc.killed and not oldest.transcode
+23 -2
View File
@@ -690,8 +690,8 @@ def test_a_scrubbing_viewer_cannot_queue_a_window_per_stop(monkeypatch):
def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch): def test_nothing_more_is_queued_while_the_extractor_is_saturated(monkeypatch):
pool = _manual_pool(monkeypatch) pool = _manual_pool(monkeypatch)
for rk in range(20): for rk in range(20): # twenty viewers, so no one account's share is what stops the passes
subtitles.ensure(str(rk), 0, at=0.0) subtitles.ensure(str(rk), 0, at=0.0, viewer=rk)
windows = [a for _, a in pool.jobs if a[3] is not None] windows = [a for _, a in pool.jobs if a[3] is not None]
assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT assert len(windows) == subtitles._MAX_WINDOWS_INFLIGHT
assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT assert len(pool.jobs) - len(windows) == subtitles._MAX_FULL_INFLIGHT
@@ -971,6 +971,27 @@ def test_a_window_is_not_refused_while_full_passes_hold_their_slots(monkeypatch)
assert full.jobs == [], "past its bound no full pass is queued" assert full.jobs == [], "past its bound no full pass is queued"
def test_one_account_cannot_hold_every_full_pass_slot(monkeypatch):
"""The full-pass slots are global and minutes long each: one account opening subtitles on film
after film held them all, and nobody else's track ever completed. Past its share an account
still gets windows — what makes a track watchable now — and the next account still gets a pass."""
win, full = _ManualPool(), _ManualPool()
monkeypatch.setattr(subtitles, "_WIN_POOL", win)
monkeypatch.setattr(subtitles, "_FULL_POOL", full)
monkeypatch.setattr(subtitles, "_extract_and_publish", lambda *a: None)
for rk in range(subtitles._MAX_FULL_PER_USER + 1):
subtitles.ensure(str(1000 + rk), 1, at=600.0, viewer=7)
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER
assert len(win.jobs) == subtitles._MAX_FULL_PER_USER + 1, (
"the account over its share still gets windows"
)
subtitles.ensure("2000", 1, at=600.0, viewer=8)
assert len(full.jobs) == subtitles._MAX_FULL_PER_USER + 1, "another account is not shut out"
full.run_all() # the account's passes end: its share is free again
subtitles.ensure("3000", 1, at=600.0, viewer=7)
assert any(a[1] == "3000" and a[3] is None for _, a in full.jobs)
def test_a_poll_after_shutdown_queues_nothing(monkeypatch): def test_a_poll_after_shutdown_queues_nothing(monkeypatch):
pool = _manual_pool(monkeypatch) pool = _manual_pool(monkeypatch)
subtitles.shutdown() subtitles.shutdown()
+5 -1
View File
@@ -193,7 +193,11 @@
}, },
"plex_max_transcodes": { "plex_max_transcodes": {
"label": "Max concurrent transcodes", "label": "Max concurrent transcodes",
"hint": "Cap on simultaneous transcode sessions for non-H.264 files. Direct-play and remux (stream-copy) files aren't limited." "hint": "Cap on simultaneous transcode sessions for non-H.264 files. Remux (stream-copy) sessions count only toward the overall session limit; direct-play files aren't limited."
},
"plex_max_sessions": {
"label": "Max concurrent playback sessions",
"hint": "Cap on all simultaneous playback sessions, remux (stream-copy) included — each one writes a full segmented copy of the film to the scratch volume. One account can hold at most two. Direct-play files aren't sessions and aren't limited."
}, },
"plex_transcode_hw": { "plex_transcode_hw": {
"label": "Hardware transcode", "label": "Hardware transcode",
+5 -1
View File
@@ -193,7 +193,11 @@
}, },
"plex_max_transcodes": { "plex_max_transcodes": {
"label": "Max párhuzamos transcode", "label": "Max párhuzamos transcode",
"hint": "A nem-H.264 fájlok egyidejű transcode-munkameneteinek felső korlátja. A direktben játszható és a remux (stream-copy) fájlokra nincs korlát." "hint": "A nem-H.264 fájlok egyidejű transcode-munkameneteinek felső korlátja. A remux (stream-copy) munkamenetek csak az összesített munkamenet-korlátba számítanak bele; a direktben játszható fájlokra nincs korlát."
},
"plex_max_sessions": {
"label": "Max párhuzamos lejátszási munkamenet",
"hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a remuxot (stream-copy) is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A direktben játszható fájlok nem munkamenetek, rájuk nincs korlát."
}, },
"plex_transcode_hw": { "plex_transcode_hw": {
"label": "Hardveres transcode", "label": "Hardveres transcode",