diff --git a/Dockerfile b/Dockerfile
index 716bf849..5a43df66 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 \
diff --git a/VERSION b/VERSION
index e218073d..182dd955 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.76.0
\ No newline at end of file
+0.78.0
\ No newline at end of file
diff --git a/backend/app/config.py b/backend/app/config.py
index 0af52517..bd916a90 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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
diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py
index c8aa4ca6..1f75d071 100644
--- a/backend/app/plex/stream.py
+++ b/backend/app/plex/stream.py
@@ -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
diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py
index fab04797..04d37f36 100644
--- a/backend/app/routes/plex/playback.py
+++ b/backend/app/routes/plex/playback.py
@@ -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,
}
diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py
index 05299179..1fa7802f 100644
--- a/backend/app/sysconfig.py
+++ b/backend/app/sysconfig.py
@@ -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 ---
diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py
new file mode 100644
index 00000000..4be08777
--- /dev/null
+++ b/backend/tests/test_plex_playback_session.py
@@ -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
diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py
index f23453a6..00c55ece 100644
--- a/backend/tests/test_plex_stream.py
+++ b/backend/tests/test_plex_stream.py
@@ -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
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 61cc1608..30712d86 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -425,7 +425,7 @@ export default function PlexInfo({
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index c8e775c2..f781926d 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -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(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(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(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