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 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