release: 0.78.0 (feature/e6-plex-player-polish)
gate / frontend (push) Failing after 1m35s
gate / backend (push) Successful in 2m20s

This commit is contained in:
2026-08-30 02:26:25 +02:00
24 changed files with 1459 additions and 46 deletions
+7 -4
View File
@@ -45,12 +45,15 @@ RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /downloads \
&& chown appuser /app /downloads
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
ARG DENO_VERSION=v2.9.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg unzip curl ca-certificates \
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
&& chmod +x /usr/local/bin/deno \
+1 -1
View File
@@ -1 +1 @@
0.76.0
0.78.0
+15 -2
View File
@@ -258,9 +258,22 @@ class Settings(BaseSettings):
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
# side — so it runs far less often (daily) than the incremental pass.
plex_watch_reconcile_interval_min: int = 1440
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
# expensive; direct-serve (browser-compatible files) has no such limit.
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
# 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.
plex_max_transcodes: int = 4
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# 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
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
plex_transcode_hw: str = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
# encodes at source resolution.
plex_transcode_preset: str = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
+190 -17
View File
@@ -3,7 +3,10 @@
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on
the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC
when needed. HEVC/VP9 (playable="transcode") needs a full re-encode and is deferred to P3.
when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode
to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with
software libx264 as the automatic fallback where the render node is absent or the source codec isn't
hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs.
Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss`
(fast keyframe seek). A seek beyond the generated region restarts the session at the new offset,
@@ -16,6 +19,7 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
"""
import logging
import os
import re
import shutil
import subprocess
@@ -27,6 +31,7 @@ from sqlalchemy.orm import Session as DbSession
from app import sysconfig
from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
@@ -83,11 +88,20 @@ _sessions: dict[str, "HlsSession"] = {}
class HlsSession:
def __init__(
self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str
self,
key: str,
directory: Path,
proc: subprocess.Popen,
start_s: float,
entry: str,
transcode: bool = False,
hw: bool = False,
):
self.key = key
self.dir = directory
self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
self.start_s = start_s # the REQUESTED seek offset
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
@@ -127,6 +141,118 @@ _HLS_TAIL = [
]
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`:
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
# 1080p transcodes are fine and the API stays responsive.
# * libx264 software — the automatic fallback where `/dev/dri` is absent (dev box, or prod before the
# iGPU enablement) or the source codec isn't hardware-decodable (AV1/XviD on Gen 9.5). CPU-bound, so
# it reserves a core via `-threads` (see encode_thread_cap).
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
_VAAPI_DEVICE = "/dev/dri/renderD128"
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
# when the render node is present, because the GPU can't decode it to feed its own encoder.
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
def _hwaccel_in_opts(hw: bool) -> list[str]:
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
round-trip to system memory. Empty for the software path."""
if not hw:
return []
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
def _venc_opts(
transcode: bool,
hw: bool,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]:
"""The video-encoder args that replace `-c:v copy` for a transcode session (returns the copy args
when not transcoding). A re-encode must MINT its own keyframes at the segment boundaries — the copy
path leaned on the source's keyframes, but a re-encoded stream has none the HLS muxer can cut on
unless we force them (`-force_key_frames` every `_SEG_SECONDS`), else segments aren't independent and
seeking breaks. `preset`/`crf`/`max_height` (software only) come from admin sysconfig."""
if not transcode:
return ["-c:v", "copy"]
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
# The GPU encode is cheap, so it runs at source resolution (max_height is a software-only knob).
return ["-vf", "scale_vaapi=format=nv12", "-c:v", "h264_vaapi", *force_kf]
# Software fallback: same browser-safe recipe as downloads/edit.py (libx264). yuv420p forces 8-bit
# High (not High10) so any browser decodes it; -threads reserves a core so the CPU-bound encode can't
# starve request handling / playback on the shared box.
args: list[str] = []
if max_height and max_height > 0:
# Downscale-ONLY to the height cap (min(ih,H)), keeping aspect with an even width (-2), so a tall
# source doesn't make the software re-encode ruinously expensive. The comma in min() is escaped
# (\,) so ffmpeg's filter parser reads one filter, not two. Never upscales a smaller source.
args += ["-vf", f"scale=-2:min(ih\\,{max_height})"]
args += [
"-c:v",
"libx264",
"-preset",
preset,
"-crf",
str(crf),
"-pix_fmt",
"yuv420p",
"-threads",
str(encode_thread_cap()),
*force_kf,
]
return args
def _render_node_present() -> bool:
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob."""
return Path(_VAAPI_DEVICE).exists()
def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
(trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
feed itself → software libx264)."""
if not transcode:
return False
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
if mode == "off" or not _render_node_present():
return False
if mode == "on":
return True
return (item.codec_video or "").lower() in _HW_DECODABLE
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in
# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of
# this. Reuses the download child's nice default so all co-located re-encoders share one handicap.
_SW_TRANSCODE_NICE = settings.download_child_nice
def _sw_child_preexec() -> None:
"""Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it
can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail
playback."""
try:
os.nice(_SW_TRANSCODE_NICE)
except OSError:
pass
def _probe_audio_langs(src: Path) -> list[str]:
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
@@ -160,8 +286,17 @@ def _ffmpeg_cmd(
audio_ord: int | None,
aoff: float = 0.0,
audio_langs: list[str] | None = None,
transcode: bool = False,
hw: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux.
venc = _venc_opts(transcode, hw, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
@@ -182,7 +317,7 @@ def _ffmpeg_cmd(
args += ["-map", "0:v:0"]
for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"]
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
var = ["v:0,agroup:aud"]
for i, lang in enumerate(audio_langs):
entry = f"a:{i},agroup:aud,name:a{i}"
@@ -203,7 +338,7 @@ def _ffmpeg_cmd(
else:
ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += _HLS_TAIL + [
"-hls_segment_filename",
str(out_dir / "seg_%d.ts"),
@@ -258,12 +393,14 @@ def _kill(s: HlsSession) -> None:
def _enforce_cap(cap: int) -> None:
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
if len(_sessions) <= cap:
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py:
# 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.
tx = [(key, s) for key, s in _sessions.items() if s.transcode]
if len(tx) <= cap:
return
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
: len(_sessions) - cap
]:
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
_kill(s)
_sessions.pop(key, None)
@@ -275,18 +412,33 @@ def start_session(
audio_ord: int | None = None,
aoff: float = 0.0,
multi: bool = False,
transcode: bool = False,
) -> HlsSession | None:
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
Returns None if the local file can't be read."""
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
HLS rendition so the client switches audio without a restart; otherwise a single audio track
(`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here). Returns None if the
local file can't be read."""
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
key = item.rating_key
start_s = max(0.0, float(start_s))
hw = _want_hw(db, item, transcode)
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
# ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
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
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a 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
with _lock:
old = _sessions.pop(key, None)
if old is not None:
@@ -297,12 +449,31 @@ def start_session(
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None
src,
start_s,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
transcode=transcode,
hw=hw,
preset=preset,
crf=crf,
max_height=max_height,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
proc,
start_s,
"master.m3u8" if is_multi else "index.m3u8",
transcode=transcode,
hw=hw,
)
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
@@ -329,12 +500,14 @@ def start_session(
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
break
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
key,
start_s,
s.media_start_s,
is_multi,
audio_ord,
transcode,
hw,
)
return s
+10 -8
View File
@@ -532,15 +532,13 @@ def stream_session(
db: Session = Depends(get_db),
) -> dict:
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
it = item_or_404(db, rating_key)
if it.playable == "transcode":
raise HTTPException(
status_code=501, detail="This format needs transcoding (a later phase)."
)
transcode = it.playable == "transcode"
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
# the client can switch audio without a restart — even for otherwise direct-playable files.
if it.playable == "direct" and audio is None and not aoff and not multi:
@@ -551,15 +549,19 @@ def stream_session(
}
if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
if s is None:
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
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
return {
"mode": "hls",
"start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
"transcode": s.transcode,
"hw": s.hw,
}
+8
View File
@@ -159,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
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.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
# Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
# libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
+107
View File
@@ -0,0 +1,107 @@
"""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, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode
return SimpleNamespace(
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=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
+219 -1
View File
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
second half of this file pins what keeps it off a LIVE session's segments."""
import os
from pathlib import Path
import pytest
@@ -164,10 +165,152 @@ class _FakeProc:
class _FakeItem:
def __init__(self, rating_key="12345", file_path="/media/movie.mkv"):
def __init__(self, rating_key="12345", file_path="/media/movie.mkv", codec_video="h264"):
self.rating_key = rating_key
self.file_path = file_path
self.playable = "remux"
self.codec_video = codec_video
class _CapturingProc:
"""Records the argv + kwargs each Popen was called with, then behaves like the ffmpeg proc."""
calls: list[tuple] = []
def __init__(self, *args, **kwargs):
_CapturingProc.calls.append((args, kwargs))
def poll(self):
return None
def _venc(cmd):
"""The video codec ffmpeg was told to use (the value after the first `-c:v`)."""
return cmd[cmd.index("-c:v") + 1]
class TestTranscodeCommand:
"""The transcode video re-encode (playable='transcode'): the SAME HLS command as remux with the
video encoder swapped in for `-c:v copy`. The HW path can only be exercised on prod (VAAPI needs
Linux + Intel /dev/dri), so its SHAPE is pinned here; the software path is the one UAT'd locally."""
SRC = Path("/media/movie.mkv")
OUT = Path("/out")
def test_remux_still_stream_copies_the_video(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None)
assert _venc(cmd) == "copy"
assert "libx264" not in cmd and "h264_vaapi" not in cmd
assert "-hwaccel" not in cmd # no GPU decode on the copy path
assert "-force_key_frames" not in cmd # copy leans on the source's own keyframes
def test_software_transcode_uses_libx264_and_mints_keyframes(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=False)
assert _venc(cmd) == "libx264"
assert "copy" not in cmd # the video is no longer stream-copied
# 8-bit High (yuv420p), not High10, so any browser decodes a 10-bit HEVC source.
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
assert "-threads" in cmd # a core reserved for request handling / playback
assert "-hwaccel" not in cmd # software path: CPU decode, no render node needed
# A forced keyframe at every segment boundary so the HLS muxer cuts independent segments.
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
def test_hardware_transcode_uses_the_full_vaapi_pipeline(self):
cmd = stream._ffmpeg_cmd(self.SRC, 0.0, self.OUT, None, transcode=True, hw=True)
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
# GPU decode options must precede the input so the source is decoded on-device.
assert cmd[cmd.index("-hwaccel") + 1] == "vaapi"
assert "-hwaccel_output_format" in cmd
assert cmd[cmd.index("-vaapi_device") + 1] == stream._VAAPI_DEVICE
assert cmd.index("-hwaccel") < cmd.index("-i")
# scale_vaapi=format=nv12 downconverts a 10-bit source to 8-bit nv12 for h264_vaapi.
assert "scale_vaapi=format=nv12" in cmd
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
def test_multi_audio_transcode_reencodes_the_video_variant(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, audio_langs=["eng", "hun"], transcode=True, hw=False
)
assert _venc(cmd) == "libx264" # video re-encoded…
assert "-var_stream_map" in cmd # …but still multi-rendition (client-side audio switch)
assert "copy" not in cmd
def test_software_preset_and_crf_come_from_the_knobs(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, preset="slow", crf=18
)
assert cmd[cmd.index("-preset") + 1] == "slow"
assert cmd[cmd.index("-crf") + 1] == "18"
def test_software_max_height_caps_with_a_downscale_only_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=720
)
# Downscale-only, aspect-preserving, even width; the comma in min() escaped so it's ONE filter.
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,720)"
def test_software_max_height_zero_means_no_scale_filter(self):
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=False, max_height=0
)
assert "-vf" not in cmd # source resolution kept
def test_hardware_ignores_max_height(self):
# The GPU encode is cheap, so it runs at source resolution; max_height is a software-only knob.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, max_height=720
)
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
class TestWantHw:
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
@staticmethod
def _policy(monkeypatch, mode):
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
def test_no_hw_when_not_transcoding(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
def test_no_hw_without_a_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
# required — HW is impossible without it).
self._policy(monkeypatch, "on")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
class TestColdStartWarmup:
@@ -225,3 +368,78 @@ class TestColdStartWarmup:
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
assert s is not None
assert waited == ["seg_0.ts"]
class TestTranscodeStartSession:
"""The seam TestTranscodeCommand can't reach: start_session reading the three admin knobs
(plex_transcode_preset/crf/max_height) and threading them + transcode/hw into the ffmpeg argv and
the HlsSession. A swapped knob here is a SILENT mis-encode (still plays), so pin the whole wiring
end-to-end with a real (captured) Popen argv — not just _ffmpeg_cmd called with literals."""
@pytest.fixture
def capturing_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
monkeypatch.setattr(stream, "_sessions", {})
src = tmp_path / "movie.mkv"
src.write_bytes(b"x")
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
# Distinct sentinels per knob so a crf<->max_height swap (both ints, both via get_int) fails.
monkeypatch.setattr(
stream.sysconfig,
"get_int",
lambda db, k: {
"plex_transcode_crf": 18,
"plex_transcode_max_height": 480,
"plex_max_transcodes": 4,
}[k],
)
monkeypatch.setattr(
stream.sysconfig,
"get_str",
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
)
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
_CapturingProc.calls = []
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
return tmp_path
def test_software_transcode_threads_the_knobs_into_the_command(
self, capturing_env, monkeypatch
):
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
s = stream.start_session(
db=None, 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
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "libx264"
assert cmd[cmd.index("-preset") + 1] == "slow" # plex_transcode_preset
assert cmd[cmd.index("-crf") + 1] == "18" # plex_transcode_crf, NOT max_height
assert cmd[cmd.index("-vf") + 1] == "scale=-2:min(ih\\,480)" # plex_transcode_max_height
# The CPU-bound child is niced via preexec_fn on POSIX (the API process itself must not be).
if os.name == "posix":
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
s = stream.start_session(
db=None, 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
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
assert (
"-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12"
) # no SW cap
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
# the preexec nice are gated behind `transcode=True`).
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy"
assert kwargs.get("preexec_fn") is None
+1 -1
View File
@@ -425,7 +425,7 @@ export default function PlexInfo({
<button
onClick={onClose}
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
aria-label={t("plex.player.back")}
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
+102 -8
View File
@@ -9,6 +9,7 @@ import {
ArrowLeft,
Clock,
Download,
Gauge,
Info,
Keyboard,
Languages,
@@ -38,8 +39,10 @@ import {
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { UpNextCard } from "./UpNextCard";
import {
useAutoSkip,
useControlsVisibility,
@@ -95,6 +98,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -114,6 +120,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
const [buffering, setBuffering] = useState(false);
// The current session re-encodes the video (playable="transcode"). Its cold-start (the first transcode
// segment) is genuinely slower than a remux stall, so the initial-load overlay says "Preparing stream…"
// instead of the generic "Loading…" — honest, and it stops a warm-up reading as a hang.
const [sessionIsTranscode, setSessionIsTranscode] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
@@ -171,6 +181,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
// "D" toggles the stats-for-nerds debug overlay (active mode, codec, dropped frames, buffer…).
const [statsOpen, setStatsOpen] = useState(false);
const statsOpenRef = useRef(false);
statsOpenRef.current = statsOpen;
// Up-next end-card: shown at episode end (with a next item) instead of an instant jump (S4).
const [upNextOpen, setUpNextOpen] = useState(false);
const upNextOpenRef = useRef(false);
upNextOpenRef.current = upNextOpen;
const upNextPrefRef = useRef(prefs.upNext);
upNextPrefRef.current = prefs.upNext;
const now = useWallClock();
durationRef.current = duration;
@@ -186,6 +206,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setReady(false);
setBuffering(false);
setLoadError(null);
// Clear the prior item's transcode flag up front so the cold-start overlay can't show a stale
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
let sess;
try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
@@ -206,6 +230,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
);
return;
}
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
@@ -514,7 +543,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
if (nextId) go(nextId);
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
if (nextId && upNextPrefRef.current) setUpNextOpen(true);
else if (nextId) go(nextId);
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
// instead of stranding a black, ended <video> on screen.
else onClose();
@@ -523,6 +556,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
const advanceUpNext = useCallback(() => {
setUpNextOpen(false);
if (nextId) go(nextId);
}, [nextId, go]);
useEffect(() => setUpNextOpen(false), [id]);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
@@ -533,6 +574,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else onClose();
}, [onClose]);
@@ -618,6 +661,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
@@ -639,6 +686,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
@@ -774,7 +823,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
rebuffer/seek (ready, no error) has no visible label, so give it an sr-only one via the
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
{!ready ? (
<span>{t("plex.player.loading")}</span>
<span>
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
</span>
) : buffering ? (
<span className="sr-only">{t("plex.player.buffering")}</span>
) : null}
@@ -785,9 +836,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{(menuOpen ||
tracksOpen ||
helpOpen ||
infoOpen ||
statsOpen ||
upNextOpen ||
skipProgress != null) && <BackClose onBack={backIntent} />}
{/* Top bar */}
<div
@@ -936,9 +991,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
<div className="flex items-center gap-3 text-white">
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(absRef.current - 10)}
align="start"
>
<Skip10 dir="back" />
</Ctrl>
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
<Skip10 dir="forward" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
@@ -1114,6 +1179,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
)}
</div>
)}
<Ctrl label={t("plex.player.stats.title")} onClick={() => setStatsOpen((v) => !v)}>
<Gauge className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
@@ -1130,6 +1198,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
{/* Stats-for-nerds debug overlay (toggle with "d" or the gauge button). Non-modal corner panel —
the video keeps playing and stays interactive behind it. */}
{statsOpen && (
<PlexStatsOverlay
videoRef={videoRef}
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
onClose={() => setStatsOpen(false)}
/>
)}
{/* Up-next end-card (episode end, next item queued, pref on). Non-modal — the finished frame
stays behind it; the card's own countdown advances unless the viewer cancels. */}
{upNextOpen && nextId && (
<UpNextCard
nextId={nextId}
onAdvance={advanceUpNext}
onCancel={() => setUpNextOpen(false)}
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
@@ -1150,7 +1243,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.back")}
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
@@ -1170,6 +1263,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
checked={prefs.autoSkipCredits}
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
/>
<Toggle
label={t("plex.player.upNext.pref")}
checked={prefs.upNext}
onChange={(v) => patchPrefs({ upNext: v })}
/>
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
<Slider
label={t("plex.player.autoSkipDelay")}
@@ -0,0 +1,131 @@
import { type RefObject, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type Hls from "hls.js";
import { X } from "lucide-react";
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
// offset. No data layer — the refs are already in hand. Doubles as the S1 verification surface (it shows
// the transcode mode + that the re-encode is engaged). Toggled with "d" / the gauge button / its X.
export function PlexStatsOverlay({
videoRef,
hlsRef,
playable,
hwRef,
sessionStartRef,
absRef,
durationRef,
onClose,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
onClose: () => void;
}) {
const { t } = useTranslation();
const [rows, setRows] = useState<StatRow[]>([]);
useEffect(() => {
const sample = () => {
const v = videoRef.current;
const hls = hlsRef.current;
let dropped = 0;
let total = 0;
try {
const q = v?.getVideoPlaybackQuality?.();
if (q) {
dropped = q.droppedVideoFrames;
total = q.totalVideoFrames;
}
} catch {
/* getVideoPlaybackQuality unsupported — leave 0/0 (renders as a dash) */
}
let bufferAhead = 0;
if (v) {
const now = v.currentTime;
for (let i = 0; i < v.buffered.length; i++) {
if (now >= v.buffered.start(i) && now <= v.buffered.end(i)) {
bufferAhead = v.buffered.end(i) - now;
break;
}
}
}
let level: HlsLevelInfo | null = null;
if (hls) {
// currentLevel is -1 while auto-ABR hasn't settled; fall back to loadLevel for the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
level = {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
}
setRows(
buildStatRows({
playable,
hwEncoder: hwRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
droppedFrames: dropped,
totalFrames: total,
bufferAheadS: bufferAhead,
hlsLevel: level,
sessionStartS: sessionStartRef.current ?? 0,
abs: absRef.current ?? 0,
duration: durationRef.current ?? 0,
}),
);
};
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
return (
<div
role="region"
aria-label={t("plex.player.stats.title")}
onClick={(e) => e.stopPropagation()}
className="absolute left-4 top-16 z-menu w-64 rounded-xl border border-white/15 bg-black/80 p-3 text-white shadow-2xl backdrop-blur-sm"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("plex.player.stats.title")}
</span>
<button
onClick={onClose}
aria-label={t("plex.player.close")}
className="rounded p-0.5 text-white/60 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen
reader announce the changing values continuously and drown out real player announcements.
The overlay is a role="region" (labelled above) a user reads on demand, not a status feed. */}
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-2xs">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-white/55">{t(r.labelKey)}</dt>
<dd className="text-right font-mono tabular-nums text-white/90">{r.value}</dd>
</div>
))}
</dl>
</div>
);
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// The up-next end-card's core behaviour (E6 S4): the countdown that auto-advances at ~10s (the whole
// point — it REPLACED the old instant jump, so a regression that fires early or never is exactly the
// bug to guard), the "Play now"/Cancel controls, and the episode heading's null season/episode
// fallback. i18n is key-passthrough; `../lib/api` is mocked so no network runs; the countdown uses
// performance.now, which vitest's fake timers advance alongside setInterval.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("../lib/api", () => ({
api: { plexItem: vi.fn(), plexImageUrl: () => "/img" },
}));
import { UpNextCard } from "./UpNextCard";
import { api } from "../lib/api";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
});
function renderCard(opts: { onAdvance?: () => void; onCancel?: () => void } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<UpNextCard
nextId="e7"
onAdvance={opts.onAdvance ?? vi.fn()}
onCancel={opts.onCancel ?? vi.fn()}
/>
</QueryClientProvider>,
);
}
describe("UpNextCard", () => {
it("auto-advances after the ~10s countdown, and NOT before", () => {
vi.useFakeTimers();
// A never-resolving detail query: the heading is irrelevant here and this avoids async state
// updates racing the fake clock.
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
renderCard({ onAdvance });
act(() => {
vi.advanceTimersByTime(9000);
});
expect(onAdvance).not.toHaveBeenCalled(); // still counting down
act(() => {
vi.advanceTimersByTime(1200);
});
expect(onAdvance).toHaveBeenCalledTimes(1); // crossed zero → advanced exactly once
});
it("'Play now' advances immediately, without waiting for the countdown", () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
renderCard({ onAdvance });
fireEvent.click(screen.getByRole("button", { name: /playNow/ }));
expect(onAdvance).toHaveBeenCalledTimes(1);
});
it("Cancel dismisses via onCancel and does not advance", () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));
const onAdvance = vi.fn();
const onCancel = vi.fn();
renderCard({ onAdvance, onCancel });
fireEvent.click(screen.getByText("plex.player.upNext.cancel"));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onAdvance).not.toHaveBeenCalled();
});
it("an episode heading falls back to S?·E? when season/episode are null", async () => {
(api.plexItem as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "e7",
kind: "episode",
show_title: "Westworld",
title: "The Adversary",
season_number: null,
episode_number: null,
});
renderCard();
expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
const COUNTDOWN_S = 10;
export function UpNextCard({
nextId,
onAdvance,
onCancel,
}: {
nextId: string;
onAdvance: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang),
});
const next = nextQ.data;
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
const advanceRef = useRef(onAdvance);
advanceRef.current = onAdvance;
useEffect(() => {
const started = performance.now();
const iv = window.setInterval(() => {
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
if (left <= 0) {
window.clearInterval(iv);
setRemaining(0);
advanceRef.current();
} else {
setRemaining(left);
}
}, 100);
return () => window.clearInterval(iv);
}, []);
const secs = Math.ceil(remaining);
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
return (
<div
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
{t("plex.player.upNext.title")}
</div>
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<button
onClick={onAdvance}
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
>
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="rgba(0,0,0,0.25)"
strokeWidth="2"
/>
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C * (1 - frac)}
/>
</svg>
<Play className="h-2.5 w-2.5 fill-black" />
</span>
{t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span>
</button>
<button
onClick={onCancel}
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
>
{t("plex.player.upNext.cancel")}
</button>
</div>
</div>
<button
onClick={onCancel}
aria-label={t("plex.player.upNext.cancel")}
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
@@ -1,6 +1,49 @@
import { type ReactNode } from "react";
import { RotateCcw } from "lucide-react";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
// triangles) and from Restart's plain RotateCcw. The arrowhead is an L-corner at the arc's open end,
// the same construction lucide uses for RotateCcw/RotateCw, so it sits consistently in the bar.
export function Skip10({ dir }: { dir: "back" | "forward" }) {
const back = dir === "back";
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-6 h-6"
aria-hidden="true"
>
{back ? (
<>
<path d="M4 8.5 A8 8 0 1 1 4 12" />
<path d="M4 8.5 L4 4 M4 8.5 L8.5 8.5" />
</>
) : (
<>
<path d="M20 8.5 A8 8 0 1 0 20 12" />
<path d="M20 8.5 L20 4 M20 8.5 L15.5 8.5" />
</>
)}
<text
x="12"
y="14.5"
textAnchor="middle"
fontSize="8"
fontWeight={700}
fill="currentColor"
stroke="none"
>
10
</text>
</svg>
);
}
// --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) {
return (
+17 -1
View File
@@ -166,7 +166,23 @@
},
"plex_max_transcodes": {
"label": "Max concurrent transcodes",
"hint": "Cap on simultaneous CPU transcodes for incompatible files (a later fallback). Direct-playable files aren't limited."
"hint": "Cap on simultaneous transcode sessions for non-H.264 files. Direct-play and remux (stream-copy) files aren't limited."
},
"plex_transcode_hw": {
"label": "Hardware transcode",
"hint": "GPU (VAAPI) encoding policy: auto (GPU when available and the source is decodable, else software), on (force GPU), off (always software)."
},
"plex_transcode_preset": {
"label": "Software preset",
"hint": "libx264 preset for the software fallback (e.g. veryfast, faster, medium). Slower presets shrink the stream but cost more CPU. Ignored on the GPU path."
},
"plex_transcode_crf": {
"label": "Software quality (CRF)",
"hint": "libx264 CRF for the software fallback (0–51; lower = better quality, larger). 20 is a good default. Ignored on the GPU path."
},
"plex_transcode_max_height": {
"label": "Software max height",
"hint": "Cap the software transcode's output height in pixels (e.g. 1080; 0 = keep source) so a tall source stays affordable to re-encode. The GPU path keeps source resolution."
}
},
"save": "Save",
+25 -1
View File
@@ -155,11 +155,15 @@
"player": {
"loading": "Loading…",
"buffering": "Buffering…",
"preparing": "Preparing stream…",
"back": "Back",
"close": "Close",
"skipIntro": "Skip intro",
"skipCredits": "Skip credits",
"playPause": "Play / pause (space)",
"restart": "Restart from beginning",
"rewind10": "Back 10 seconds",
"forward10": "Forward 10 seconds",
"prev": "Previous episode",
"next": "Next episode",
"mute": "Mute (m)",
@@ -187,7 +191,27 @@
"help": "Show this help",
"audio": "Cycle audio track",
"subtitle": "Cycle subtitle",
"fineSeek": "Fine seek"
"fineSeek": "Fine seek",
"stats": "Stats for nerds"
},
"upNext": {
"title": "Up next",
"playNow": "Play now",
"cancel": "Cancel",
"pref": "Up-next card"
},
"stats": {
"title": "Stats for nerds",
"mode": "Mode",
"transport": "Transport",
"resolution": "Resolution",
"codec": "Codec",
"bitrate": "Bitrate",
"level": "HLS level",
"frames": "Dropped frames",
"buffer": "Buffer ahead",
"offset": "Session offset",
"position": "Position"
},
"settings": "Settings",
"sync": "Offsets",
+17 -1
View File
@@ -166,7 +166,23 @@
},
"plex_max_transcodes": {
"label": "Max párhuzamos transcode",
"hint": "A nem kompatibilis fájlok egyidejű CPU-transcode-jainak felső korlátja (későbbi tartalék). A direktben játszható fájlokra nincs korlát."
"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."
},
"plex_transcode_hw": {
"label": "Hardveres transcode",
"hint": "GPU (VAAPI) kódolási házirend: auto (GPU, ha elérhető és a forrás dekódolható, különben szoftver), on (GPU kényszerítése), off (mindig szoftver)."
},
"plex_transcode_preset": {
"label": "Szoftveres preset",
"hint": "libx264 preset a szoftveres tartalékhoz (pl. veryfast, faster, medium). A lassabb preset kisebb streamet ad, de több CPU-t eszik. A GPU-úton figyelmen kívül marad."
},
"plex_transcode_crf": {
"label": "Szoftveres minőség (CRF)",
"hint": "libx264 CRF a szoftveres tartalékhoz (0–51; alacsonyabb = jobb minőség, nagyobb méret). A 20 jó alapérték. A GPU-úton figyelmen kívül marad."
},
"plex_transcode_max_height": {
"label": "Szoftveres max. magasság",
"hint": "A szoftveres transcode kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja."
}
},
"save": "Mentés",
+25 -1
View File
@@ -155,11 +155,15 @@
"player": {
"loading": "Betöltés…",
"buffering": "Pufferelés…",
"preparing": "Stream előkészítése…",
"back": "Vissza",
"close": "Bezárás",
"skipIntro": "Intro átugrása",
"skipCredits": "Stáblista átugrása",
"playPause": "Lejátszás / szünet (szóköz)",
"restart": "Újra az elejéről",
"rewind10": "10 másodperc vissza",
"forward10": "10 másodperc előre",
"prev": "Előző rész",
"next": "Következő rész",
"mute": "Némítás (m)",
@@ -187,7 +191,27 @@
"help": "Súgó megjelenítése",
"audio": "Hangsáv váltása",
"subtitle": "Felirat váltása",
"fineSeek": "Finom léptetés"
"fineSeek": "Finom léptetés",
"stats": "Statisztika (nerdöknek)"
},
"upNext": {
"title": "Következik",
"playNow": "Lejátszás most",
"cancel": "Mégse",
"pref": "Következik-kártya"
},
"stats": {
"title": "Statisztika (nerdöknek)",
"mode": "Mód",
"transport": "Átvitel",
"resolution": "Felbontás",
"codec": "Kodek",
"bitrate": "Bitráta",
"level": "HLS szint",
"frames": "Eldobott képkockák",
"buffer": "Puffer előre",
"offset": "Munkamenet-eltolás",
"position": "Pozíció"
},
"settings": "Beállítások",
"sync": "Eltolások",
+2
View File
@@ -390,6 +390,8 @@ export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
+2
View File
@@ -45,6 +45,7 @@ export type PlexPlayerPrefs = {
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
upNext: boolean; // at episode end (with a next item) show an up-next card + countdown before advancing
};
export const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
@@ -71,6 +72,7 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
autoSkipIntro: false,
autoSkipCredits: false,
autoSkipDelay: 5,
upNext: true,
};
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
buildStatRows,
fmtBitrate,
fmtBufferSecs,
fmtClock,
fmtCodec,
fmtFrames,
fmtLevel,
fmtMode,
fmtRes,
type PlexStatsInput,
} from "./plexStats";
const DASH = "—";
describe("plexStats formatters", () => {
it("fmtBitrate scales kbps/Mbps and dashes zero/undefined", () => {
expect(fmtBitrate(undefined)).toBe(DASH);
expect(fmtBitrate(0)).toBe(DASH);
expect(fmtBitrate(820_000)).toBe("820 kbps");
expect(fmtBitrate(4_200_000)).toBe("4.2 Mbps");
expect(fmtBitrate(1_000_000)).toBe("1.0 Mbps");
});
it("fmtBufferSecs shows one decimal, dashes negatives/non-finite", () => {
expect(fmtBufferSecs(12.34)).toBe("12.3 s");
expect(fmtBufferSecs(0)).toBe("0.0 s");
expect(fmtBufferSecs(-1)).toBe(DASH);
expect(fmtBufferSecs(Infinity)).toBe(DASH);
});
it("fmtFrames shows ratio + drop %, dashes when nothing decoded", () => {
expect(fmtFrames(0, 0)).toBe(DASH);
expect(fmtFrames(0, 1000)).toBe("0 / 1000 (0.00%)");
expect(fmtFrames(5, 1000)).toBe("5 / 1000 (0.50%)");
expect(fmtFrames(30, 1000)).toBe("30 / 1000 (3.0%)");
});
it("fmtRes dashes an undecoded 0×0 frame", () => {
expect(fmtRes(1920, 1080)).toBe("1920×1080");
expect(fmtRes(0, 0)).toBe(DASH);
expect(fmtRes(1920, 0)).toBe(DASH);
});
it("fmtClock renders m:ss and dashes bad input", () => {
expect(fmtClock(0)).toBe("0:00");
expect(fmtClock(9)).toBe("0:09");
expect(fmtClock(75)).toBe("1:15");
expect(fmtClock(3661)).toBe("61:01");
expect(fmtClock(-1)).toBe(DASH);
});
it("fmtLevel is 1-based with resolution, handles auto and empty", () => {
expect(fmtLevel(null)).toBe(DASH);
expect(fmtLevel({ index: 1, total: 4, width: 1280, height: 720 })).toBe("2 / 4 · 1280×720");
expect(fmtLevel({ index: -1, total: 3 })).toBe("auto / 3");
expect(fmtLevel({ index: 0, total: 0 })).toBe(DASH);
});
it("fmtCodec strips profile suffixes and dashes when absent", () => {
expect(fmtCodec("avc1.640028", "mp4a.40.2")).toBe("avc1 / mp4a");
expect(fmtCodec("hvc1.1.6.L120", undefined)).toBe("hvc1");
expect(fmtCodec(undefined, undefined)).toBe(DASH);
});
});
describe("buildStatRows", () => {
const base: PlexStatsInput = {
playable: "remux",
transport: "hls",
videoWidth: 1280,
videoHeight: 720,
droppedFrames: 2,
totalFrames: 500,
bufferAheadS: 8.2,
hlsLevel: { index: 0, total: 1, width: 1280, height: 720, bitrate: 3_000_000 },
sessionStartS: 65,
abs: 130,
duration: 5214,
};
it("includes the HLS-only rows (codec/bitrate/level) for hls transport", () => {
const keys = buildStatRows(base).map((r) => r.key);
expect(keys).toEqual([
"mode",
"transport",
"res",
"codec",
"bitrate",
"level",
"frames",
"buffer",
"offset",
"position",
]);
});
it("drops the HLS-only rows for direct play", () => {
const keys = buildStatRows({ ...base, transport: "direct", hlsLevel: null }).map((r) => r.key);
expect(keys).not.toContain("codec");
expect(keys).not.toContain("bitrate");
expect(keys).not.toContain("level");
expect(keys).toContain("res");
});
it("surfaces the classification as the mode value and formats position", () => {
const rows = buildStatRows({ ...base, playable: "transcode" });
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode");
expect(rows.find((r) => r.key === "offset")?.value).toBe("1:05");
expect(rows.find((r) => r.key === "position")?.value).toBe("2:10 / 86:54");
});
it("appends the transcode encoder to the mode row when known", () => {
const gpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: true });
expect(gpu.find((r) => r.key === "mode")?.value).toBe("transcode · GPU");
const cpu = buildStatRows({ ...base, playable: "transcode", hwEncoder: false });
expect(cpu.find((r) => r.key === "mode")?.value).toBe("transcode · CPU");
});
it("shows an empty classification honestly as a dash", () => {
expect(buildStatRows({ ...base, playable: "" }).find((r) => r.key === "mode")?.value).toBe(
DASH,
);
});
});
describe("fmtMode", () => {
it("appends the encoder only for a transcode with a known encoder", () => {
expect(fmtMode("transcode", true)).toBe("transcode · GPU");
expect(fmtMode("transcode", false)).toBe("transcode · CPU");
expect(fmtMode("transcode", null)).toBe("transcode"); // encoder unknown → bare mode
expect(fmtMode("transcode")).toBe("transcode");
expect(fmtMode("remux", true)).toBe("remux"); // not a transcode → no encoder suffix
expect(fmtMode("")).toBe(DASH);
});
});
+148
View File
@@ -0,0 +1,148 @@
// Pure formatters + row builder for the Plex player "stats for nerds" overlay (E6 S2). Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef and hands them here; this returns i18n-keyed rows the overlay renders. No data pipeline — the
// HTML5 <video> + hls.js already expose everything (intrinsic dims, getVideoPlaybackQuality, levels).
export interface PlexStatsInput {
// The item's codec classification (backend `classify_playable`): the "active mode" a viewer cares
// about — direct copy, remux (stream-copy → HLS), or transcode (re-encode). Empty until detail loads.
playable: string; // "direct" | "remux" | "transcode" | ""
transport: "direct" | "hls"; // session transport (raw file vs HLS) — orthogonal to `playable`
// For a transcode session, which encoder actually ran: true = VAAPI hardware, false = libx264
// software, null/undefined = not a transcode (or unknown) — so the mode row can read "transcode · GPU"
// vs "transcode · CPU", the visible confirmation of which S1 path engaged.
hwEncoder?: boolean | null;
videoWidth: number; // intrinsic decoded dims (0 while the browser can't decode / pre-metadata)
videoHeight: number;
droppedFrames: number; // getVideoPlaybackQuality().droppedVideoFrames
totalFrames: number; // getVideoPlaybackQuality().totalVideoFrames
bufferAheadS: number; // seconds buffered ahead of the playhead
hlsLevel: HlsLevelInfo | null; // null for direct play / Safari-native HLS
sessionStartS: number; // media-clock offset the current session was opened at
abs: number; // current media time (absolute, session offset already folded in)
duration: number;
}
export interface HlsLevelInfo {
index: number; // active level index (-1 = auto/undetermined)
total: number; // number of renditions
width?: number;
height?: number;
bitrate?: number; // bits per second
videoCodec?: string;
audioCodec?: string;
}
export interface StatRow {
key: string; // stable react key
labelKey: string; // i18n key under plex.player.stats.*
value: string;
}
const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
export function fmtBitrate(bps: number | undefined): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps`;
return `${Math.round(bps / 1000)} kbps`;
}
// Seconds of buffer ahead of the playhead → "12.3 s".
export function fmtBufferSecs(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return `${s.toFixed(1)} s`;
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
export function fmtFrames(dropped: number, total: number): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
return `${dropped} / ${total} (${pct.toFixed(pct < 1 ? 2 : 1)}%)`;
}
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
// which is itself the tell for a not-yet-playable codec pre-transcode.
export function fmtRes(w: number, h: number): string {
if (w <= 0 || h <= 0) return DASH;
return `${w}×${h}`;
}
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
export function fmtClock(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
const total = Math.floor(s);
const m = Math.floor(total / 60);
const sec = total % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
}
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
export function fmtLevel(l: HlsLevelInfo | null): string {
if (!l || l.total <= 0) return DASH;
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
const res = l.width && l.height ? ` · ${l.width}×${l.height}` : "";
return `${idx}${res}`;
}
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
const a = audio?.split(".")[0] || null;
if (!v && !a) return DASH;
return [v, a].filter(Boolean).join(" / ");
}
// Active mode, with the transcode encoder appended when known: "transcode · GPU" (VAAPI hardware) or
// "transcode · CPU" (libx264 software) — the at-a-glance confirmation of which S1 path engaged. Non-
// transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens (not i18n'd),
// consistent with the other technical values in this overlay (e.g. transport "hls").
export function fmtMode(playable: string, hwEncoder?: boolean | null): string {
const mode = playable || DASH;
if (playable !== "transcode" || hwEncoder == null) return mode;
return `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
}
// Build the ordered, i18n-keyed rows the overlay renders. Rows whose data is absent still show a dash
// (honest "not available" beats a missing row) — except HLS-only rows, dropped entirely for direct play.
export function buildStatRows(s: PlexStatsInput): StatRow[] {
const rows: StatRow[] = [
{ key: "mode", labelKey: "plex.player.stats.mode", value: fmtMode(s.playable, s.hwEncoder) },
{ key: "transport", labelKey: "plex.player.stats.transport", value: s.transport },
{
key: "res",
labelKey: "plex.player.stats.resolution",
value: fmtRes(s.videoWidth, s.videoHeight),
},
];
if (s.transport === "hls") {
rows.push(
{
key: "codec",
labelKey: "plex.player.stats.codec",
value: fmtCodec(s.hlsLevel?.videoCodec, s.hlsLevel?.audioCodec),
},
{
key: "bitrate",
labelKey: "plex.player.stats.bitrate",
value: fmtBitrate(s.hlsLevel?.bitrate),
},
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
);
}
rows.push(
{
key: "frames",
labelKey: "plex.player.stats.frames",
value: fmtFrames(s.droppedFrames, s.totalFrames),
},
{ key: "buffer", labelKey: "plex.player.stats.buffer", value: fmtBufferSecs(s.bufferAheadS) },
{ key: "offset", labelKey: "plex.player.stats.offset", value: fmtClock(s.sessionStartS) },
{
key: "position",
labelKey: "plex.player.stats.position",
value: `${fmtClock(s.abs)} / ${fmtClock(s.duration)}`,
},
);
return rows;
}
+19
View File
@@ -14,6 +14,25 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.78.0",
date: "2026-08-30",
summary:
"The Plex player now plays any format. Videos in codecs your browser can't open natively — HEVC/H.265, VP9, AV1, MPEG-2 and older formats — are transcoded on the fly, hardware-accelerated on a supported server GPU with an automatic software fallback. Plus three player touches: an up-next card at episode end, on-screen 10-second skip buttons, and a stats overlay.",
features: [
"Play any Plex video in the browser, even formats it can't open natively (HEVC/H.265, VP9, AV1, MPEG-2, older codecs). Siftlode re-encodes the video on the fly to something every browser plays, with seeking, subtitles, audio-track switching and audio offset all still working. It uses a supported server GPU when available (barely touching the CPU) and falls back to software automatically, so playback just works.",
"At the end of an episode, an up-next card shows the next one with a ~10-second countdown — let it advance, hit “Play now”, or Cancel to stay on the finished frame. Binge on your terms instead of an instant jump (on by default; togglable).",
"On-screen 10-second rewind and forward buttons now flank Play/Pause, matching the ← / → keys — a discoverable control for touch and mouse, distinct from the previous/next-episode buttons.",
"A “stats for nerds” overlay (press D) shows the live playback mode (direct / remux / transcode), resolution, dropped frames, buffer and more — handy for seeing exactly how a video is being served.",
],
fixes: [
"While a transcoded stream warms up, the player shows an honest “Preparing stream…” instead of a plain spinner, so a slightly longer start doesn't read as a hang.",
"The browser (and mouse) Back button now closes the stats or up-next overlay first, instead of jumping straight out of the player.",
],
chores: [
"The transcode path uses VAAPI hardware encoding on a supported Intel GPU, with a de-prioritised, concurrency-capped software fallback so it never starves the rest of the app. The whole epic went through a multi-agent deep-review — findings fixed and re-reviewed each round until a round came back clean.",
],
},
{
version: "0.76.0",
date: "2026-08-29",