Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66132b6dda | ||
|
|
d09f021f0c | ||
|
|
d7e9240e54 | ||
|
|
f2fa49b778 | ||
|
|
d4ed45b2eb | ||
|
|
375f6e58be | ||
|
|
1a102ce81a | ||
|
|
bfd45a155e | ||
|
|
1102ad6a28 | ||
|
|
c1a3b7b935 | ||
|
|
bf98b48517 | ||
|
|
b563155a37 | ||
|
|
915319fa35 | ||
|
|
a2a8f5681d | ||
|
|
4827aebb7f | ||
|
|
840526b1b8 | ||
|
|
10d69cb316 | ||
|
|
a86131e062 | ||
|
|
a84f2db310 | ||
|
|
872d91f540 | ||
|
|
4eaa68ceeb | ||
|
|
00539a4d97 | ||
|
|
dc24d353e0 | ||
|
|
ce96032edb | ||
|
|
0658f9550b | ||
|
|
f162788135 | ||
|
|
968674bc6a | ||
|
|
90ae9f6e88 |
+20
-4
@@ -45,18 +45,34 @@ RUN adduser --disabled-password --gecos "" appuser \
|
|||||||
&& mkdir -p /downloads \
|
&& mkdir -p /downloads \
|
||||||
&& chown appuser /app /downloads
|
&& chown appuser /app /downloads
|
||||||
|
|
||||||
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1).
|
||||||
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
|
||||||
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
# 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
|
ARG DENO_VERSION=v2.9.1
|
||||||
RUN apt-get update \
|
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 \
|
&& 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 \
|
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
||||||
&& chmod +x /usr/local/bin/deno \
|
&& chmod +x /usr/local/bin/deno \
|
||||||
&& rm /tmp/deno.zip \
|
&& rm /tmp/deno.zip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
|
||||||
|
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
|
||||||
|
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
|
||||||
|
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
|
||||||
|
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
|
||||||
|
# source is added only for this one install and removed after, so it can't drift the rest of the image.
|
||||||
|
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
|
||||||
|
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
|
||||||
|
&& rm /etc/apt/sources.list.d/bookworm.sources \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||||
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
||||||
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode)
|
||||||
|
|
||||||
|
Adds the columns the playback-class filter needs:
|
||||||
|
* transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it
|
||||||
|
(codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items.
|
||||||
|
* video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed.
|
||||||
|
* media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed).
|
||||||
|
|
||||||
|
Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the
|
||||||
|
same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works
|
||||||
|
immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't
|
||||||
|
carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth
|
||||||
|
backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at`
|
||||||
|
start NULL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0077_plex_playback_class"
|
||||||
|
down_revision: Union[str, None] = "0076_media_asset_kind"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True))
|
||||||
|
op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"])
|
||||||
|
# Codec-only derivation for existing rows — keep this codec set identical to
|
||||||
|
# plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a
|
||||||
|
# decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE plex_items
|
||||||
|
SET transcode_hw = CASE
|
||||||
|
WHEN playable = 'transcode'
|
||||||
|
THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video')
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items")
|
||||||
|
op.drop_column("plex_items", "media_probed_at")
|
||||||
|
op.drop_column("plex_items", "video_bit_depth")
|
||||||
|
op.drop_column("plex_items", "transcode_hw")
|
||||||
+15
-2
@@ -258,9 +258,22 @@ class Settings(BaseSettings):
|
|||||||
# that catches what the incremental history feed can't — notably an un-watch done on the Plex
|
# 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.
|
# side — so it runs far less often (daily) than the incremental pass.
|
||||||
plex_watch_reconcile_interval_min: int = 1440
|
plex_watch_reconcile_interval_min: int = 1440
|
||||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
# Max concurrent transcode sessions (playable="transcode"). Low by default — the software-fallback
|
||||||
# expensive; direct-serve (browser-compatible files) has no such limit.
|
# 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
|
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 `_select_encode`.
|
||||||
|
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`
|
# 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
|
# 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
|
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast
|
||||||
|
|||||||
@@ -447,9 +447,9 @@ def run_ffprobe_json(
|
|||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
|
||||||
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
|
||||||
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
|
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
|
||||||
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
|
used by both the download worker's finalize and the upload ingest) both go through it, so the
|
||||||
two drifting copies."""
|
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
|
||||||
args = ["ffprobe", "-v", "error"]
|
args = ["ffprobe", "-v", "error"]
|
||||||
if show_streams:
|
if show_streams:
|
||||||
args.append("-show_streams")
|
args.append("-show_streams")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.config import settings
|
|||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, credentials, formats, mediakind, service, storage
|
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
_edit_staging_dir,
|
_edit_staging_dir,
|
||||||
_job_status,
|
_job_status,
|
||||||
@@ -475,15 +475,14 @@ def _download(
|
|||||||
asset.rel_path = rel
|
asset.rel_path = rel
|
||||||
asset.size_bytes = size
|
asset.size_bytes = size
|
||||||
asset.duration_s = meta.duration_s
|
asset.duration_s = meta.duration_s
|
||||||
asset.width = int(info["width"]) if info.get("width") else None
|
# Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's
|
||||||
asset.height = int(info["height"]) if info.get("height") else None
|
# `info` dict, whose codec fields are absent for some sources (Facebook among them):
|
||||||
|
# deriving media_kind from those nulls misclassified a real H.264 video as a
|
||||||
|
# download-only 'file' with no in-app play/edit. Same probe the upload ingest uses.
|
||||||
|
asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts(
|
||||||
|
final, info
|
||||||
|
)
|
||||||
asset.container = ext
|
asset.container = ext
|
||||||
asset.vcodec = (
|
|
||||||
info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
|
||||||
)
|
|
||||||
asset.acodec = (
|
|
||||||
info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
|
||||||
)
|
|
||||||
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
|
||||||
asset.title = meta.title
|
asset.title = meta.title
|
||||||
asset.uploader = meta.uploader
|
asset.uploader = meta.uploader
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Ground-truth media probe for a produced library file — the SINGLE place both ingest paths ask
|
||||||
|
"what is actually on disk?".
|
||||||
|
|
||||||
|
`media_kind` (and the stored `vcodec`/`acodec`/`width`/`height`) must reflect the real file, not an
|
||||||
|
extractor's metadata: yt-dlp's `info` dict omits codec fields for some sources (e.g. Facebook), and
|
||||||
|
deriving the kind from those nulls misclassified a genuine H.264 video as a download-only `file`
|
||||||
|
(no in-app play). So the download worker (`pipeline`) and the local upload ingest (`upload`) both
|
||||||
|
probe the finished file here; `mediakind.derive` then turns the probed codecs into the kind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.downloads import edit as editmod
|
||||||
|
|
||||||
|
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
||||||
|
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
||||||
|
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
||||||
|
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
||||||
|
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
||||||
|
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
||||||
|
_IMAGE_FORMATS = {
|
||||||
|
"image2",
|
||||||
|
"image2pipe",
|
||||||
|
"png_pipe",
|
||||||
|
"mjpeg",
|
||||||
|
"mjpeg_pipe",
|
||||||
|
"jpeg_pipe",
|
||||||
|
"gif",
|
||||||
|
"webp_pipe",
|
||||||
|
"bmp_pipe",
|
||||||
|
"tiff_pipe",
|
||||||
|
"ico",
|
||||||
|
"svg_pipe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _facts_from_probe(
|
||||||
|
data: dict,
|
||||||
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) parsed from a NON-None ffprobe JSON dict. A still
|
||||||
|
image / image-sequence container yields no vcodec (→ generic 'file'); a video stream flagged
|
||||||
|
`attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a thumbnail, not a real video
|
||||||
|
track, so an audio file with album art still classifies as audio."""
|
||||||
|
fmt = data.get("format") or {}
|
||||||
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
||||||
|
if fmt_names & _IMAGE_FORMATS:
|
||||||
|
return None, None, None, None, None
|
||||||
|
|
||||||
|
vcodec = acodec = None
|
||||||
|
width = height = None
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
kind = stream.get("codec_type")
|
||||||
|
if kind == "video" and vcodec is None:
|
||||||
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
||||||
|
continue # cover art, not a video track
|
||||||
|
vcodec = stream.get("codec_name")
|
||||||
|
width = stream.get("width")
|
||||||
|
height = stream.get("height")
|
||||||
|
elif kind == "audio" and acodec is None:
|
||||||
|
acodec = stream.get("codec_name")
|
||||||
|
|
||||||
|
duration = None
|
||||||
|
try:
|
||||||
|
raw = fmt.get("duration")
|
||||||
|
if raw is not None:
|
||||||
|
duration = int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = None
|
||||||
|
return vcodec, acodec, width, height, duration
|
||||||
|
|
||||||
|
|
||||||
|
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
||||||
|
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
||||||
|
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
|
||||||
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
|
if data is None:
|
||||||
|
return None, None, None, None, None
|
||||||
|
return _facts_from_probe(data)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_media_facts(
|
||||||
|
path: Path, info: dict
|
||||||
|
) -> tuple[str | None, str | None, int | None, int | None]:
|
||||||
|
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
|
||||||
|
(the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among
|
||||||
|
them), so deriving media_kind from it there labelled a real H.264 video a download-only 'file'.
|
||||||
|
|
||||||
|
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
|
||||||
|
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
||||||
|
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
||||||
|
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
||||||
|
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
|
||||||
|
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
|
||||||
|
dimensions still gap-fill from `info` in either case."""
|
||||||
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
||||||
|
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||||
|
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
|
info_w = int(info["width"]) if info.get("width") else None
|
||||||
|
info_h = int(info["height"]) if info.get("height") else None
|
||||||
|
if data is None:
|
||||||
|
# ffprobe could not read the file — fall back to the extractor's info entirely.
|
||||||
|
return info_v, info_a, info_w, info_h
|
||||||
|
pv, pa, pw, ph, _dur = _facts_from_probe(data)
|
||||||
|
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
||||||
|
# gap-fills the cosmetic width/height the probe couldn't read.
|
||||||
|
return pv, pa, pw if pw is not None else info_w, ph if ph is not None else info_h
|
||||||
@@ -24,70 +24,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import mediakind, quota, service, storage
|
from app.downloads import mediakind, probe, quota, service, storage
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
|
|
||||||
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
|
|
||||||
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
|
|
||||||
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
|
|
||||||
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
|
||||||
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
|
||||||
_IMAGE_FORMATS = {
|
|
||||||
"image2",
|
|
||||||
"image2pipe",
|
|
||||||
"png_pipe",
|
|
||||||
"mjpeg",
|
|
||||||
"mjpeg_pipe",
|
|
||||||
"jpeg_pipe",
|
|
||||||
"gif",
|
|
||||||
"webp_pipe",
|
|
||||||
"bmp_pipe",
|
|
||||||
"tiff_pipe",
|
|
||||||
"ico",
|
|
||||||
"svg_pipe",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
||||||
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
|
||||||
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
|
||||||
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
|
|
||||||
|
|
||||||
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
|
||||||
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
|
||||||
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
|
||||||
if data is None:
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
fmt = data.get("format") or {}
|
|
||||||
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
|
||||||
if fmt_names & _IMAGE_FORMATS:
|
|
||||||
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
vcodec = acodec = None
|
|
||||||
width = height = None
|
|
||||||
for stream in data.get("streams", []):
|
|
||||||
kind = stream.get("codec_type")
|
|
||||||
if kind == "video" and vcodec is None:
|
|
||||||
if (stream.get("disposition") or {}).get("attached_pic"):
|
|
||||||
continue # cover art, not a video track
|
|
||||||
vcodec = stream.get("codec_name")
|
|
||||||
width = stream.get("width")
|
|
||||||
height = stream.get("height")
|
|
||||||
elif kind == "audio" and acodec is None:
|
|
||||||
acodec = stream.get("codec_name")
|
|
||||||
|
|
||||||
duration = None
|
|
||||||
try:
|
|
||||||
raw = fmt.get("duration")
|
|
||||||
if raw is not None:
|
|
||||||
duration = int(float(raw))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
duration = None
|
|
||||||
return vcodec, acodec, width, height, duration
|
|
||||||
|
|
||||||
|
|
||||||
def _title_from_filename(filename: str) -> str:
|
def _title_from_filename(filename: str) -> str:
|
||||||
"""A human display title from the original upload name: the stem with separators tidied to
|
"""A human display title from the original upload name: the stem with separators tidied to
|
||||||
@@ -112,7 +51,7 @@ def ingest_upload(
|
|||||||
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
||||||
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
||||||
|
|
||||||
vcodec, acodec, width, height, duration = _probe(src_path)
|
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
|
||||||
kind = mediakind.derive(vcodec, acodec)
|
kind = mediakind.derive(vcodec, acodec)
|
||||||
|
|
||||||
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
||||||
|
|||||||
@@ -1127,6 +1127,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||||
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||||
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a
|
||||||
|
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||||
|
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||||
|
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||||
|
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||||
|
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||||
|
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||||
|
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||||
|
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||||
|
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||||
|
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||||
|
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||||
|
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||||
|
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||||
|
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
||||||
markers: Mapped[list | None] = mapped_column(JSON)
|
markers: Mapped[list | None] = mapped_column(JSON)
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
|||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": _art_url(it.rating_key, it.art_key),
|
"art": _art_url(it.rating_key, it.art_key),
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
|
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
|||||||
"duration_seconds": e.duration_s,
|
"duration_seconds": e.duration_s,
|
||||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||||
"playable": e.playable,
|
"playable": e.playable,
|
||||||
|
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from app.models import PlexItem
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
|
|
||||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||||
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
|||||||
return q.where(*conds) if conds else q
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||||||
|
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||||
|
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||||
|
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||||
|
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
||||||
|
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||||
|
|
||||||
|
|
||||||
|
def playback_class_expr():
|
||||||
|
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
|
||||||
|
precede the generic transcode branch (ordered evaluation)."""
|
||||||
|
return case(
|
||||||
|
(PlexItem.playable == "direct", "direct"),
|
||||||
|
(PlexItem.playable == "remux", "remux"),
|
||||||
|
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
|
||||||
|
(PlexItem.playable == "transcode", "transcode_sw"),
|
||||||
|
else_=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def playable_conds(sel: list[str]) -> list:
|
||||||
|
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||||||
|
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||||||
|
keep = [c for c in sel if c in PLAYBACK_CLASSES]
|
||||||
|
return [playback_class_expr().in_(keep)] if keep else []
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LibraryFilters:
|
class LibraryFilters:
|
||||||
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||||
@@ -228,6 +257,7 @@ class LibraryFilters:
|
|||||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||||
collections: str | None = None # CSV of collection rating_keys
|
collections: str | None = None # CSV of collection rating_keys
|
||||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||||
|
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
def as_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -247,6 +277,7 @@ class LibraryFilters:
|
|||||||
"keywords": self.keywords,
|
"keywords": self.keywords,
|
||||||
"collections": self.collections,
|
"collections": self.collections,
|
||||||
"collection_mode": self.collection_mode,
|
"collection_mode": self.collection_mode,
|
||||||
|
"playable": self.playable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+418
-41
@@ -3,7 +3,10 @@
|
|||||||
Playback is from the LOCAL physical file. Browser-compatible files (playable="direct") are served
|
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
|
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
|
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`
|
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,
|
(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 logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,8 +31,10 @@ from sqlalchemy.orm import Session as DbSession
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.downloads.edit import encode_thread_cap
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex import paths
|
from app.plex import paths
|
||||||
|
from app.plex.sync import HW_DECODABLE_CODECS
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
||||||
@@ -83,11 +89,22 @@ _sessions: dict[str, "HlsSession"] = {}
|
|||||||
|
|
||||||
class HlsSession:
|
class HlsSession:
|
||||||
def __init__(
|
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,
|
||||||
|
tonemap: bool = False,
|
||||||
):
|
):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
|
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||||
|
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
|
||||||
|
self.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
|
||||||
self.start_s = start_s # the REQUESTED seek offset
|
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
|
# 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).
|
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||||
@@ -127,6 +144,269 @@ _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. The encoder + tonemap are chosen per-session by `_select_encode`:
|
||||||
|
# * 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"
|
||||||
|
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
|
||||||
|
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
|
||||||
|
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
|
||||||
|
# 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 it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
|
||||||
|
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
|
||||||
|
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
|
||||||
|
# codec; aliased to the old name to keep the call sites below unchanged.
|
||||||
|
_HW_DECODABLE = HW_DECODABLE_CODECS
|
||||||
|
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
|
||||||
|
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||||
|
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
|
||||||
|
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
|
||||||
|
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||||
|
|
||||||
|
|
||||||
|
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||||
|
_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path
|
||||||
|
_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime)
|
||||||
|
_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_video_kind(src: Path) -> str:
|
||||||
|
"""Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`:
|
||||||
|
|
||||||
|
* `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi).
|
||||||
|
* `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the
|
||||||
|
OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264.
|
||||||
|
* `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would
|
||||||
|
visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was
|
||||||
|
needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and
|
||||||
|
downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path
|
||||||
|
is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.)
|
||||||
|
|
||||||
|
Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via color_transfer); the
|
||||||
|
Plex mirror doesn't store these. Returns `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback
|
||||||
|
in start_session backstops a HW session that still produces nothing, so a probe miss only costs the
|
||||||
|
rare wasted first attempt, never a stranded item."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=pix_fmt,bits_per_raw_sample,color_transfer",
|
||||||
|
"-of",
|
||||||
|
"default=noprint_wrappers=1",
|
||||||
|
str(src),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return _VK_HWSAFE
|
||||||
|
pix_fmt = bits = transfer = ""
|
||||||
|
for line in out.stdout.splitlines():
|
||||||
|
k, _, v = line.strip().partition("=")
|
||||||
|
v = v.strip().lower()
|
||||||
|
if k == "pix_fmt":
|
||||||
|
pix_fmt = v
|
||||||
|
elif k == "bits_per_raw_sample":
|
||||||
|
bits = v
|
||||||
|
elif k == "color_transfer":
|
||||||
|
transfer = v
|
||||||
|
if transfer in _HDR_TRANSFERS:
|
||||||
|
return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert
|
||||||
|
# >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix
|
||||||
|
# (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent.
|
||||||
|
if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt):
|
||||||
|
return _VK_SDR10
|
||||||
|
return _VK_HWSAFE
|
||||||
|
|
||||||
|
|
||||||
|
def _opencl_present() -> bool:
|
||||||
|
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
|
||||||
|
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
|
||||||
|
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
|
||||||
|
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
|
||||||
|
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
||||||
|
try:
|
||||||
|
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> 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.
|
||||||
|
|
||||||
|
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
|
||||||
|
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
|
||||||
|
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
|
||||||
|
if not hw:
|
||||||
|
return []
|
||||||
|
if tonemap:
|
||||||
|
return [
|
||||||
|
"-init_hw_device",
|
||||||
|
f"vaapi=va:{_VAAPI_DEVICE}",
|
||||||
|
"-init_hw_device",
|
||||||
|
"opencl=ocl@va",
|
||||||
|
"-hwaccel",
|
||||||
|
"vaapi",
|
||||||
|
"-hwaccel_output_format",
|
||||||
|
"vaapi",
|
||||||
|
"-hwaccel_device",
|
||||||
|
"va",
|
||||||
|
]
|
||||||
|
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||||
|
|
||||||
|
|
||||||
|
def _venc_opts(
|
||||||
|
transcode: bool,
|
||||||
|
hw: bool,
|
||||||
|
tonemap: bool = False,
|
||||||
|
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 and tonemap:
|
||||||
|
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
|
||||||
|
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
|
||||||
|
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
|
||||||
|
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
|
||||||
|
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
|
||||||
|
return [
|
||||||
|
"-filter_hw_device",
|
||||||
|
"ocl",
|
||||||
|
"-vf",
|
||||||
|
"hwmap=derive_device=opencl,"
|
||||||
|
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
|
||||||
|
"hwmap=derive_device=vaapi:reverse=1",
|
||||||
|
"-c:v",
|
||||||
|
"h264_vaapi",
|
||||||
|
*force_kf,
|
||||||
|
]
|
||||||
|
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 _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
|
||||||
|
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
|
||||||
|
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
|
||||||
|
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
|
||||||
|
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
|
||||||
|
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
|
||||||
|
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
|
||||||
|
|
||||||
|
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
|
||||||
|
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
|
||||||
|
|
||||||
|
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
|
||||||
|
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
|
||||||
|
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
|
||||||
|
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
|
||||||
|
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
|
||||||
|
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
|
||||||
|
if not transcode:
|
||||||
|
return (False, False)
|
||||||
|
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||||
|
if mode == "off" or not _render_node_present():
|
||||||
|
return (False, False)
|
||||||
|
if mode == "on":
|
||||||
|
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
|
||||||
|
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
|
||||||
|
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
|
||||||
|
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
|
||||||
|
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
|
||||||
|
return (True, tonemap)
|
||||||
|
# auto
|
||||||
|
if (item.codec_video or "").lower() not in _HW_DECODABLE:
|
||||||
|
return (False, False)
|
||||||
|
kind = _probe_video_kind(src)
|
||||||
|
if kind == _VK_HWSAFE:
|
||||||
|
return (True, False) # 8-bit SDR → full-GPU scale path
|
||||||
|
if kind == _VK_HDR:
|
||||||
|
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
|
||||||
|
return (True, True) if _opencl_present() else (False, False)
|
||||||
|
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
|
||||||
|
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
|
||||||
|
return (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||||
|
"""The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that
|
||||||
|
assert only whether a session runs on the GPU (production decides via `_select_encode` directly)."""
|
||||||
|
return _select_encode(db, item, transcode, src)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# 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]:
|
def _probe_audio_langs(src: Path) -> list[str]:
|
||||||
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
|
"""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."""
|
Used to build the multi-rendition var_stream_map. Empty on probe failure → single-audio path."""
|
||||||
@@ -160,8 +440,19 @@ def _ffmpeg_cmd(
|
|||||||
audio_ord: int | None,
|
audio_ord: int | None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
audio_langs: list[str] | None = None,
|
audio_langs: list[str] | None = None,
|
||||||
|
transcode: bool = False,
|
||||||
|
hw: bool = False,
|
||||||
|
tonemap: bool = False,
|
||||||
|
preset: str = "veryfast",
|
||||||
|
crf: int = 20,
|
||||||
|
max_height: int = 0,
|
||||||
) -> list[str]:
|
) -> 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. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
|
||||||
|
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
|
||||||
|
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
|
||||||
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
|
||||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
# 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
|
# 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
|
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||||
@@ -182,7 +473,7 @@ def _ffmpeg_cmd(
|
|||||||
args += ["-map", "0:v:0"]
|
args += ["-map", "0:v:0"]
|
||||||
for i in range(len(audio_langs)):
|
for i in range(len(audio_langs)):
|
||||||
args += ["-map", f"{ai}:a:{i}?"]
|
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"]
|
var = ["v:0,agroup:aud"]
|
||||||
for i, lang in enumerate(audio_langs):
|
for i, lang in enumerate(audio_langs):
|
||||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||||
@@ -203,7 +494,7 @@ def _ffmpeg_cmd(
|
|||||||
else:
|
else:
|
||||||
ao = audio_ord if audio_ord is not None else 0
|
ao = audio_ord if audio_ord is not None else 0
|
||||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
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 + [
|
args += _HLS_TAIL + [
|
||||||
"-hls_segment_filename",
|
"-hls_segment_filename",
|
||||||
str(out_dir / "seg_%d.ts"),
|
str(out_dir / "seg_%d.ts"),
|
||||||
@@ -258,12 +549,14 @@ def _kill(s: HlsSession) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _enforce_cap(cap: int) -> None:
|
def _enforce_cap(cap: int) -> None:
|
||||||
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
|
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux
|
||||||
if len(_sessions) <= cap:
|
# (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
|
return
|
||||||
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[
|
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]:
|
||||||
: len(_sessions) - cap
|
|
||||||
]:
|
|
||||||
_kill(s)
|
_kill(s)
|
||||||
_sessions.pop(key, None)
|
_sessions.pop(key, None)
|
||||||
|
|
||||||
@@ -275,11 +568,14 @@ def start_session(
|
|||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
aoff: float = 0.0,
|
aoff: float = 0.0,
|
||||||
multi: bool = False,
|
multi: bool = False,
|
||||||
|
transcode: bool = False,
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
"""(Re)start the HLS session for an item at the given offset. `transcode` (item is non-h264,
|
||||||
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
playable="transcode") re-encodes the video to H.264 (VAAPI or libx264) instead of stream-copying it;
|
||||||
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
|
otherwise the video is copied (remux). `multi` (item has >1 audio track) maps every audio track as an
|
||||||
Returns None if the local file can't be read."""
|
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)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
@@ -287,34 +583,102 @@ def start_session(
|
|||||||
start_s = max(0.0, float(start_s))
|
start_s = max(0.0, float(start_s))
|
||||||
audio_langs = _probe_audio_langs(src) if multi else []
|
audio_langs = _probe_audio_langs(src) if multi else []
|
||||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||||
with _lock:
|
tag = "multi" if is_multi else str(audio_ord)
|
||||||
old = _sessions.pop(key, None)
|
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||||
if old is not None:
|
|
||||||
_kill(old)
|
|
||||||
tag = "multi" if is_multi else str(audio_ord)
|
|
||||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
|
||||||
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
|
|
||||||
),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
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.
|
|
||||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
|
||||||
|
|
||||||
# Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
|
|
||||||
# ffmpeg producing them, and we must not hold up other sessions).
|
|
||||||
def _seg(i: int) -> Path:
|
def _seg(i: int) -> Path:
|
||||||
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
||||||
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
||||||
|
|
||||||
if wait_for(_seg(0), timeout=25.0):
|
def _spawn(
|
||||||
|
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
|
||||||
|
) -> "HlsSession | None":
|
||||||
|
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
||||||
|
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
|
||||||
|
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
|
||||||
|
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
|
||||||
|
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
|
||||||
|
# that is the seek-restart's intended last-writer-wins.
|
||||||
|
# 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")
|
||||||
|
# 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:
|
||||||
|
if replace_only is not None and _sessions.get(key) is not replace_only:
|
||||||
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
|
old = _sessions.pop(key, None)
|
||||||
|
if old is not None:
|
||||||
|
_kill(old)
|
||||||
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
|
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,
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
|
tonemap=tonemap,
|
||||||
|
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,
|
||||||
|
tonemap=tonemap,
|
||||||
|
)
|
||||||
|
_sessions[key] = s
|
||||||
|
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||||
|
# at least 1 so playback can't be capped to zero.
|
||||||
|
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||||
|
return s
|
||||||
|
|
||||||
|
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||||
|
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
|
||||||
|
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
|
||||||
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
|
if not ready and s.hw:
|
||||||
|
# We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its
|
||||||
|
# own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or
|
||||||
|
# _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no
|
||||||
|
# tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so
|
||||||
|
# the respawn is a no-op if we've been superseded: the identity check and the pop+replace share
|
||||||
|
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
|
||||||
|
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
||||||
|
log.warning(
|
||||||
|
"plex transcode: HW session produced no segments — falling back to software key=%s", key
|
||||||
|
)
|
||||||
|
respawned = _spawn(False, False, replace_only=s)
|
||||||
|
if respawned is None:
|
||||||
|
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
||||||
|
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
||||||
|
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||||
|
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||||
|
# "Not available yet") the client re-requests.
|
||||||
|
return current_session(key) or s
|
||||||
|
s = respawned
|
||||||
|
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||||
|
if ready:
|
||||||
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
||||||
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
|
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
|
||||||
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||||
@@ -326,15 +690,18 @@ def start_session(
|
|||||||
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
|
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
|
||||||
# warm, so a miss means something slow and we proceed rather than block the request further.
|
# warm, so a miss means something slow and we proceed rather than block the request further.
|
||||||
for i in range(1, _WARMUP_SEGMENTS):
|
for i in range(1, _WARMUP_SEGMENTS):
|
||||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
|
||||||
break
|
break
|
||||||
log.info(
|
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 tonemap=%s",
|
||||||
key,
|
key,
|
||||||
start_s,
|
start_s,
|
||||||
s.media_start_s,
|
s.media_start_s,
|
||||||
is_multi,
|
is_multi,
|
||||||
audio_ord,
|
audio_ord,
|
||||||
|
transcode,
|
||||||
|
s.hw,
|
||||||
|
s.tonemap,
|
||||||
)
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -347,10 +714,14 @@ def current_session(key: str) -> HlsSession | None:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
|
||||||
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
||||||
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
||||||
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
|
far beyond the generated region won't (the frontend restarts the session at a seek instead).
|
||||||
|
|
||||||
|
With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init
|
||||||
|
(10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the
|
||||||
|
whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall."""
|
||||||
end = time.time() + timeout
|
end = time.time() + timeout
|
||||||
while time.time() < end:
|
while time.time() < end:
|
||||||
try:
|
try:
|
||||||
@@ -358,6 +729,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
|||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if proc is not None and proc.poll() is not None:
|
||||||
|
# the producer exited — no more segments are coming; one last check then give up.
|
||||||
|
try:
|
||||||
|
return path.exists() and path.stat().st_size > 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return path.exists()
|
return path.exists()
|
||||||
|
|
||||||
|
|||||||
+130
-3
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
|||||||
return "direct"
|
return "direct"
|
||||||
|
|
||||||
|
|
||||||
|
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
|
||||||
|
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
|
||||||
|
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
|
||||||
|
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||||
|
# own encoder).
|
||||||
|
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
||||||
|
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
|
||||||
|
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||||
|
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_hw_capable(
|
||||||
|
playable: str | None,
|
||||||
|
vcodec: str | None,
|
||||||
|
bit_depth: int | None = None,
|
||||||
|
hdr: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
|
||||||
|
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
|
||||||
|
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
|
||||||
|
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
|
||||||
|
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
|
||||||
|
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
|
||||||
|
if playable != "transcode":
|
||||||
|
return None
|
||||||
|
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
|
||||||
|
return False
|
||||||
|
if bit_depth is not None and bit_depth > 8:
|
||||||
|
return False
|
||||||
|
return not hdr
|
||||||
|
|
||||||
|
|
||||||
|
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||||
|
"""(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType
|
||||||
|
1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the
|
||||||
|
bit-depth backfill, not the listing sync. (None, False) when absent/unparseable."""
|
||||||
|
for m in meta.get("Media") or []:
|
||||||
|
for part in m.get("Part") or []:
|
||||||
|
for s in part.get("Stream") or []:
|
||||||
|
if s.get("streamType") != 1: # 1 = video
|
||||||
|
continue
|
||||||
|
bd = s.get("bitDepth")
|
||||||
|
try:
|
||||||
|
depth = int(bd) if bd else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
depth = None
|
||||||
|
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||||
|
return depth, hdr
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _epoch(v) -> datetime | None:
|
def _epoch(v) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
||||||
@@ -217,6 +268,13 @@ def sync(db: Session) -> dict:
|
|||||||
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
||||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||||
log.warning("Plex people enrich batch failed: %s", e)
|
log.warning("Plex people enrich batch failed: %s", e)
|
||||||
|
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
||||||
|
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
||||||
|
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
||||||
|
try:
|
||||||
|
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||||
|
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||||
|
log.warning("Plex media probe batch failed: %s", e)
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
@@ -236,6 +294,49 @@ def sync(db: Session) -> dict:
|
|||||||
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
|
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
|
||||||
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
||||||
_ENRICH_BATCH = 60
|
_ENRICH_BATCH = 60
|
||||||
|
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
|
||||||
|
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
|
||||||
|
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
|
||||||
|
_PROBE_BATCH = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
|
||||||
|
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
|
||||||
|
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
|
||||||
|
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
|
||||||
|
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
|
||||||
|
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexItem)
|
||||||
|
.where(
|
||||||
|
PlexItem.playable == "transcode",
|
||||||
|
func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS),
|
||||||
|
PlexItem.media_probed_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PlexItem.id)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
n = 0
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
meta = plex.metadata(row.rating_key) or {}
|
||||||
|
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
|
||||||
|
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
|
||||||
|
continue
|
||||||
|
bit_depth, hdr = _video_stream_props(meta)
|
||||||
|
row.video_bit_depth = bit_depth
|
||||||
|
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr)
|
||||||
|
row.media_probed_at = (
|
||||||
|
now # probed (even if Plex carried no Stream — don't re-fetch forever)
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
db.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
|
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
|
||||||
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||||||
row.added_at = _epoch(meta.get("addedAt"))
|
row.added_at = _epoch(meta.get("addedAt"))
|
||||||
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
||||||
mf = _media_facts(meta)
|
mf = _media_facts(meta)
|
||||||
|
new_vcodec = mf.get("codec_video")
|
||||||
|
new_part_key = mf.get("part_key")
|
||||||
|
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
|
||||||
|
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
|
||||||
|
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
|
||||||
|
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
|
||||||
|
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
|
||||||
|
#
|
||||||
|
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
|
||||||
|
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
|
||||||
|
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
|
||||||
|
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
|
||||||
|
# an acceptable rare edge; a per-sync re-probe loop is not.
|
||||||
|
single_version = len(meta.get("Media") or []) <= 1
|
||||||
|
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
|
||||||
|
if codec_changed or (single_version and row.part_key != new_part_key):
|
||||||
|
row.video_bit_depth = None
|
||||||
|
row.media_probed_at = None
|
||||||
row.file_path = mf.get("file_path")
|
row.file_path = mf.get("file_path")
|
||||||
row.part_key = mf.get("part_key")
|
row.part_key = new_part_key
|
||||||
row.container = mf.get("container")
|
row.container = mf.get("container")
|
||||||
row.codec_video = mf.get("codec_video")
|
row.codec_video = new_vcodec
|
||||||
row.codec_audio = mf.get("codec_audio")
|
row.codec_audio = mf.get("codec_audio")
|
||||||
row.size_bytes = mf.get("size_bytes")
|
row.size_bytes = mf.get("size_bytes")
|
||||||
row.playable = classify_playable(
|
row.playable = classify_playable(
|
||||||
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
|
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
|
||||||
)
|
)
|
||||||
|
# transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once
|
||||||
|
# probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None,
|
||||||
|
# even if it had been probed as a transcode before (an audio/container flip to remux without a codec
|
||||||
|
# change wouldn't reset the watermark, so pin it here).
|
||||||
|
if row.playable != "transcode":
|
||||||
|
row.transcode_hw = None
|
||||||
|
elif row.media_probed_at is None:
|
||||||
|
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth)
|
||||||
|
|
||||||
|
|
||||||
def _prune_stale(
|
def _prune_stale(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
and_,
|
and_,
|
||||||
@@ -27,6 +28,7 @@ from app.plex import rollup
|
|||||||
from app.plex.cards import episode_card
|
from app.plex.cards import episode_card
|
||||||
from app.plex.query import (
|
from app.plex.query import (
|
||||||
FIELD_COLUMNS,
|
FIELD_COLUMNS,
|
||||||
|
PLAYBACK_CLASSES,
|
||||||
TS_CONFIG,
|
TS_CONFIG,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
apply_meta_filters,
|
||||||
@@ -36,6 +38,8 @@ from app.plex.query import (
|
|||||||
genres_col,
|
genres_col,
|
||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
movie_status_case,
|
movie_status_case,
|
||||||
|
playable_conds,
|
||||||
|
playback_class_expr,
|
||||||
show_status_case,
|
show_status_case,
|
||||||
split_csv,
|
split_csv,
|
||||||
summary_col,
|
summary_col,
|
||||||
@@ -135,6 +139,7 @@ def unified_library(
|
|||||||
PlexItem.originally_available_at.label("rel"),
|
PlexItem.originally_available_at.label("rel"),
|
||||||
PlexItem.duration_s.label("duration_s"),
|
PlexItem.duration_s.label("duration_s"),
|
||||||
PlexItem.playable.label("playable"),
|
PlexItem.playable.label("playable"),
|
||||||
|
PlexItem.transcode_hw.label("transcode_hw"),
|
||||||
cast(null(), Integer).label("season_count"),
|
cast(null(), Integer).label("season_count"),
|
||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
m_status.label("status"),
|
||||||
@@ -156,6 +161,8 @@ def unified_library(
|
|||||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||||
if f.duration_max is not None:
|
if f.duration_max is not None:
|
||||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||||
|
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||||
|
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||||
if recheck is not None:
|
if recheck is not None:
|
||||||
@@ -179,6 +186,7 @@ def unified_library(
|
|||||||
PlexShow.originally_available_at.label("rel"),
|
PlexShow.originally_available_at.label("rel"),
|
||||||
cast(null(), Integer).label("duration_s"),
|
cast(null(), Integer).label("duration_s"),
|
||||||
cast(null(), String).label("playable"),
|
cast(null(), String).label("playable"),
|
||||||
|
cast(null(), Boolean).label("transcode_hw"),
|
||||||
PlexShow.child_count.label("season_count"),
|
PlexShow.child_count.label("season_count"),
|
||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
s_status.label("status"),
|
||||||
@@ -237,6 +245,7 @@ def unified_library(
|
|||||||
"duration_seconds": r.duration_s,
|
"duration_seconds": r.duration_s,
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
"playable": r.playable,
|
"playable": r.playable,
|
||||||
|
"transcode_hw": r.transcode_hw,
|
||||||
"status": r.status,
|
"status": r.status,
|
||||||
"position_seconds": r.position_seconds,
|
"position_seconds": r.position_seconds,
|
||||||
}
|
}
|
||||||
@@ -312,6 +321,7 @@ def facets(
|
|||||||
"genres": [],
|
"genres": [],
|
||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
"studios": [],
|
"studios": [],
|
||||||
|
"playable": [],
|
||||||
"year_min": None,
|
"year_min": None,
|
||||||
"year_max": None,
|
"year_max": None,
|
||||||
"rating_max": None,
|
"rating_max": None,
|
||||||
@@ -338,6 +348,9 @@ def facets(
|
|||||||
query = query.where(model.duration_s >= pp["duration_min"])
|
query = query.where(model.duration_s >= pp["duration_min"])
|
||||||
if with_dur and pp.get("duration_max") is not None:
|
if with_dur and pp.get("duration_max") is not None:
|
||||||
query = query.where(model.duration_s <= pp["duration_max"])
|
query = query.where(model.duration_s <= pp["duration_max"])
|
||||||
|
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||||
|
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||||
|
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||||
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||||
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||||
st = aliased(PlexState)
|
st = aliased(PlexState)
|
||||||
@@ -356,6 +369,7 @@ def facets(
|
|||||||
genre_counts: dict[str, int] = {}
|
genre_counts: dict[str, int] = {}
|
||||||
cr_counts: dict[str, int] = {}
|
cr_counts: dict[str, int] = {}
|
||||||
studio_counts: dict[str, int] = {}
|
studio_counts: dict[str, int] = {}
|
||||||
|
playable_counts: dict[str, int] = {}
|
||||||
years: list[int] = []
|
years: list[int] = []
|
||||||
ratings: list[float] = []
|
ratings: list[float] = []
|
||||||
durs: list[int] = []
|
durs: list[int] = []
|
||||||
@@ -420,6 +434,23 @@ def facets(
|
|||||||
)
|
)
|
||||||
for s, c in db.execute(stq).all():
|
for s, c in db.execute(stq).all():
|
||||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||||
|
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||||
|
if kind_movie:
|
||||||
|
pcls = playback_class_expr()
|
||||||
|
pcq = (
|
||||||
|
wsq(
|
||||||
|
select(pcls.label("cls"), func.count()),
|
||||||
|
model,
|
||||||
|
ids,
|
||||||
|
{**p, "playable": None},
|
||||||
|
kind_movie,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
.where(pcls.isnot(None))
|
||||||
|
.group_by(pcls)
|
||||||
|
)
|
||||||
|
for cls, c in db.execute(pcq).all():
|
||||||
|
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||||
# Year bounds — exclude the year filter.
|
# Year bounds — exclude the year filter.
|
||||||
by = db.execute(
|
by = db.execute(
|
||||||
wsq(
|
wsq(
|
||||||
@@ -477,6 +508,11 @@ def facets(
|
|||||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||||
for s in split_csv(f.studios):
|
for s in split_csv(f.studios):
|
||||||
studio_counts.setdefault(s, 0)
|
studio_counts.setdefault(s, 0)
|
||||||
|
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||||
|
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||||
|
for c in split_csv(f.playable):
|
||||||
|
if c in PLAYBACK_CLASSES:
|
||||||
|
playable_counts.setdefault(c, 0)
|
||||||
return {
|
return {
|
||||||
"genres": [
|
"genres": [
|
||||||
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
|
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
|
||||||
@@ -488,6 +524,12 @@ def facets(
|
|||||||
{"value": s, "count": c}
|
{"value": s, "count": c}
|
||||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||||
],
|
],
|
||||||
|
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||||
|
"playable": [
|
||||||
|
{"value": c, "count": playable_counts[c]}
|
||||||
|
for c in PLAYBACK_CLASSES
|
||||||
|
if c in playable_counts
|
||||||
|
],
|
||||||
"year_min": min(years) if years else None,
|
"year_min": min(years) if years else None,
|
||||||
"year_max": max(years) if years else None,
|
"year_max": max(years) if years else None,
|
||||||
"rating_max": max(ratings) if ratings else None,
|
"rating_max": max(ratings) if ratings else None,
|
||||||
|
|||||||
@@ -411,6 +411,10 @@ def item_detail(
|
|||||||
"year": it.year,
|
"year": it.year,
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
|
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
|
||||||
|
"transcode_hw": it.transcode_hw,
|
||||||
|
"codec_video": it.codec_video,
|
||||||
|
"video_bit_depth": it.video_bit_depth,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
"library": _lib_key(db, it.library_id),
|
"library": _lib_key(db, it.library_id),
|
||||||
|
|||||||
@@ -532,15 +532,13 @@ def stream_session(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
"""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
|
(re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264:
|
||||||
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||||
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
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
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
transcode = it.playable == "transcode"
|
||||||
raise HTTPException(
|
|
||||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
|
||||||
)
|
|
||||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
# `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.
|
# 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:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
@@ -551,15 +549,21 @@ def stream_session(
|
|||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
|
s = plex_stream.start_session(db, it, start, audio, aoff, multi, transcode=transcode)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||||
|
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||||
|
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||||
|
# segment streams are otherwise identical.
|
||||||
return {
|
return {
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||||
|
"transcode": s.transcode,
|
||||||
|
"hw": s.hw,
|
||||||
|
"tonemap": s.tonemap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||||
|
# 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
|
# 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.
|
# (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 ---
|
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class TestCodecsFromStreams:
|
|||||||
|
|
||||||
|
|
||||||
class TestRunFfprobeJson:
|
class TestRunFfprobeJson:
|
||||||
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
|
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). A failed
|
||||||
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
|
||||||
parse_probe_streams string parser used to own now lives here."""
|
parse_probe_streams string parser used to own now lives here."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW /
|
||||||
|
Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change
|
||||||
|
reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane).
|
||||||
|
|
||||||
|
The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth
|
||||||
|
backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable —
|
||||||
|
the exact case the runtime VAAPI path (plex.stream) also special-cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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 sync
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranscodeHwCapable:
|
||||||
|
"""The single classifier both the catalog facet and (by codec) the runtime encoder choice key off."""
|
||||||
|
|
||||||
|
def test_non_transcode_items_are_none(self):
|
||||||
|
assert sync.transcode_hw_capable("direct", "h264") is None
|
||||||
|
assert sync.transcode_hw_capable("remux", "h264") is None
|
||||||
|
assert sync.transcode_hw_capable(None, "hevc") is None
|
||||||
|
|
||||||
|
def test_hw_decodable_8bit_sdr_is_hw(self):
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc") is True
|
||||||
|
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
|
||||||
|
|
||||||
|
def test_non_decodable_codec_is_sw(self):
|
||||||
|
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
|
||||||
|
assert sync.transcode_hw_capable("transcode", "av1") is False
|
||||||
|
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
|
||||||
|
|
||||||
|
def test_10bit_or_hdr_hevc_is_sw(self):
|
||||||
|
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
|
||||||
|
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestVideoStreamProps:
|
||||||
|
def test_reads_bit_depth_and_hdr_from_the_video_stream(self):
|
||||||
|
meta = {
|
||||||
|
"Media": [
|
||||||
|
{
|
||||||
|
"Part": [
|
||||||
|
{
|
||||||
|
"Stream": [
|
||||||
|
{"streamType": 2, "codec": "eac3"}, # audio — skipped
|
||||||
|
{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert sync._video_stream_props(meta) == (10, True)
|
||||||
|
|
||||||
|
def test_8bit_sdr_stream(self):
|
||||||
|
meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||||
|
assert sync._video_stream_props(meta) == (8, False)
|
||||||
|
|
||||||
|
def test_absent_stream_is_none_false(self):
|
||||||
|
assert sync._video_stream_props({}) == (None, False)
|
||||||
|
assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncDerivation:
|
||||||
|
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
|
||||||
|
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
|
||||||
|
|
||||||
|
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
|
||||||
|
return {
|
||||||
|
"title": "X",
|
||||||
|
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_codec_only_derivation_at_sync(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed
|
||||||
|
row2 = PlexItem(rating_key="y", kind="movie")
|
||||||
|
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
|
||||||
|
assert row2.transcode_hw is False # av1 not HW-decodable
|
||||||
|
|
||||||
|
def test_probed_value_survives_resync_when_codec_unchanged(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
# Simulate the backfill downgrading this 10-bit HEVC to software.
|
||||||
|
row.transcode_hw = False
|
||||||
|
row.video_bit_depth = 10
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
assert row.transcode_hw is False # NOT re-derived back to True
|
||||||
|
assert row.video_bit_depth == 10 and row.media_probed_at is not None
|
||||||
|
|
||||||
|
def test_codec_change_resets_the_probe(self):
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||||
|
row.transcode_hw = False
|
||||||
|
row.video_bit_depth = 10
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
# The file was replaced with an h264 remux — the stale 10-bit probe must be cleared.
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
|
||||||
|
assert row.video_bit_depth is None and row.media_probed_at is None
|
||||||
|
|
||||||
|
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
|
||||||
|
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
|
||||||
|
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
|
||||||
|
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
|
||||||
|
# part-key move must reset the watermark so the new bit depth is re-probed.
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
|
||||||
|
row.transcode_hw = True
|
||||||
|
row.video_bit_depth = 8
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
|
||||||
|
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_codec_set_matches_hw_decodable_codecs():
|
||||||
|
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
|
||||||
|
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
|
||||||
|
# equality so adding/removing a codec from the frozenset without editing the migration fails here
|
||||||
|
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
|
||||||
|
mig = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "alembic"
|
||||||
|
/ "versions"
|
||||||
|
/ "0077_plex_playback_class.py"
|
||||||
|
)
|
||||||
|
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
|
||||||
|
assert m, "could not find the codec IN(...) list in migration 0077"
|
||||||
|
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
|
||||||
|
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePlex:
|
||||||
|
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
|
||||||
|
|
||||||
|
def __init__(self, by_key):
|
||||||
|
self.by_key = by_key
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
return self.by_key.get(rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
hevc8 = PlexItem(
|
||||||
|
rating_key="h8",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="8bit",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
hevc10 = PlexItem(
|
||||||
|
rating_key="h10",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="10bit",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
xvid = PlexItem(
|
||||||
|
rating_key="xv",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="xvid",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
)
|
||||||
|
db.add_all([hevc8, hevc10, xvid])
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
|
||||||
|
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
probed = sync._probe_media_batch(db, plex)
|
||||||
|
db.refresh(hevc8)
|
||||||
|
db.refresh(hevc10)
|
||||||
|
db.refresh(xvid)
|
||||||
|
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
|
||||||
|
assert probed == 2
|
||||||
|
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
|
||||||
|
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
|
||||||
|
assert xvid.media_probed_at is None # not in the work-list
|
||||||
|
# A second run has nothing left to do (watermarks set).
|
||||||
|
assert sync._probe_media_batch(db, plex) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_stamps_even_when_plex_has_no_stream(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
item = PlexItem(
|
||||||
|
rating_key="ns",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="nostream",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array
|
||||||
|
assert sync._probe_media_batch(db, plex) == 1
|
||||||
|
db.refresh(item)
|
||||||
|
# Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW.
|
||||||
|
assert item.media_probed_at is not None and item.video_bit_depth is None
|
||||||
|
assert item.transcode_hw is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_is_bounded_and_oldest_first(db):
|
||||||
|
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
|
||||||
|
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
items = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"h{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title=f"m{i}",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
db.add_all(items) # inserted in order → ascending ids
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||||
|
for i in range(3)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
|
||||||
|
for it in items:
|
||||||
|
db.refresh(it)
|
||||||
|
# The two LOWEST-id rows were probed; the third waits for the next run.
|
||||||
|
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
|
||||||
|
assert items[2].media_probed_at is None
|
||||||
|
# The next run continues with the remaining item.
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 1
|
||||||
|
db.refresh(items[2])
|
||||||
|
assert items[2].media_probed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingPlex:
|
||||||
|
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
|
||||||
|
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
|
||||||
|
|
||||||
|
def __init__(self, by_key, bad_key):
|
||||||
|
self.by_key = by_key
|
||||||
|
self.bad_key = bad_key
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
if rating_key == self.bad_key:
|
||||||
|
raise RuntimeError("plex timeout")
|
||||||
|
return self.by_key.get(rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
good = PlexItem(
|
||||||
|
rating_key="g",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="good",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
bad = PlexItem(
|
||||||
|
rating_key="b",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="bad",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
db.add_all([good, bad])
|
||||||
|
db.commit()
|
||||||
|
plex = _RaisingPlex(
|
||||||
|
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
|
||||||
|
)
|
||||||
|
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
|
||||||
|
assert sync._probe_media_batch(db, plex) == 1
|
||||||
|
db.refresh(good)
|
||||||
|
db.refresh(bad)
|
||||||
|
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
|
||||||
|
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_classes(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexItem(
|
||||||
|
rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct"
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux"
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="thw",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="HW",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="tsw",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="SW",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
def _client(db, user, monkeypatch):
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_filters_by_playback_class(db, monkeypatch):
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pclass@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
# Software-only transcodes.
|
||||||
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
|
assert sw.status_code == 200, sw.text
|
||||||
|
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||||
|
# HW transcode + Direct Play, OR-combined.
|
||||||
|
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||||
|
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||||
|
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||||
|
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
||||||
|
assert card["transcode_hw"] is True
|
||||||
|
# No filter → everything.
|
||||||
|
allm = client.get("/api/plex/library?scope=movie")
|
||||||
|
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pclassfacet@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/plex/facets?scope=movie")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
playable = resp.json()["playable"]
|
||||||
|
# Fixed good→heavy order (not by count), one per seeded class.
|
||||||
|
assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"]
|
||||||
|
assert {p["value"]: p["count"] for p in playable} == {
|
||||||
|
"direct": 1,
|
||||||
|
"remux": 1,
|
||||||
|
"transcode_hw": 1,
|
||||||
|
"transcode_sw": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||||
|
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
|
||||||
|
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
|
||||||
|
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
|
||||||
|
# item from the feed (an honest-state violation).
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
PlexItem(
|
||||||
|
rating_key="tnull",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="Unprobed",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = User(email="pnull@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
|
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||||
|
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||||
|
assert hw.json()["items"] == [] # never HW
|
||||||
|
facets = client.get("/api/plex/facets?scope=movie")
|
||||||
|
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||||
|
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||||
|
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
|
||||||
|
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
|
||||||
|
# 'direct' must still return all four classes at their full, unnarrowed counts.
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pself@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||||
|
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
|
||||||
|
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
|
||||||
|
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
|
||||||
|
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
PlexItem(
|
||||||
|
rating_key="d2000",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="Direct2000",
|
||||||
|
playable="direct",
|
||||||
|
year=2000,
|
||||||
|
),
|
||||||
|
PlexItem(
|
||||||
|
rating_key="s2020",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="SW2020",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="mpeg4",
|
||||||
|
transcode_hw=False,
|
||||||
|
year=2020,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
user = User(email="pnarrow@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||||
|
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
|
||||||
|
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
|
||||||
@@ -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, tonemap=False
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
try:
|
||||||
|
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcode_item_routes_to_an_hls_reencode_session(db, monkeypatch):
|
||||||
|
# A non-h264 item (formerly HTTP 501) now plays: mode=hls, transcode=True threaded to start_session.
|
||||||
|
it = _item(db, playable="transcode")
|
||||||
|
user = _user(db)
|
||||||
|
captured: dict = {}
|
||||||
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mode"] == "hls"
|
||||||
|
assert body["transcode"] is True
|
||||||
|
assert captured["transcode"] is True # start_session was told to re-encode
|
||||||
|
|
||||||
|
|
||||||
|
def test_remux_item_stays_a_stream_copy_session(db, monkeypatch):
|
||||||
|
# A remux (h264) item takes the SAME HLS path but must NOT re-encode.
|
||||||
|
it = _item(db, playable="remux", rating_key="m2")
|
||||||
|
user = _user(db, "remux@example.com")
|
||||||
|
captured: dict = {}
|
||||||
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["mode"] == "hls"
|
||||||
|
assert body["transcode"] is False
|
||||||
|
assert captured["transcode"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_item_never_opens_a_session(db, monkeypatch):
|
||||||
|
# A browser-native file is served raw — start_session is never reached, so no transcode decision.
|
||||||
|
it = _item(db, playable="direct", rating_key="m3")
|
||||||
|
user = _user(db, "direct@example.com")
|
||||||
|
captured: dict = {}
|
||||||
|
resp = _post(db, user, monkeypatch, it.rating_key, captured=captured)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["mode"] == "direct"
|
||||||
|
assert "transcode" not in captured # start_session not called
|
||||||
@@ -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."""
|
second half of this file pins what keeps it off a LIVE session's segments."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -164,10 +165,308 @@ class _FakeProc:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeItem:
|
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.rating_key = rating_key
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.playable = "remux"
|
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
|
||||||
|
|
||||||
|
def test_hdr_tonemap_uses_the_opencl_graph(self):
|
||||||
|
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
|
||||||
|
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
|
||||||
|
cmd = stream._ffmpeg_cmd(
|
||||||
|
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
|
||||||
|
)
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "libx264" not in cmd
|
||||||
|
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
|
||||||
|
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
|
||||||
|
assert "opencl=ocl@va" in cmd
|
||||||
|
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
|
||||||
|
assert cmd.index("-init_hw_device") < cmd.index("-i")
|
||||||
|
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
|
||||||
|
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||||
|
vf = cmd[cmd.index("-vf") + 1]
|
||||||
|
assert "hwmap=derive_device=opencl" in vf
|
||||||
|
assert "tonemap_opencl=" in vf and "format=nv12" in vf
|
||||||
|
assert "hwmap=derive_device=vaapi:reverse=1" in vf
|
||||||
|
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
|
||||||
|
assert (
|
||||||
|
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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. A HW session on a 10-bit/HDR source
|
||||||
|
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
|
||||||
|
software — see the `_select_encode` cases below."""
|
||||||
|
|
||||||
|
SRC = Path("/media/movie.mkv")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _policy(monkeypatch, mode):
|
||||||
|
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _kind(monkeypatch, kind):
|
||||||
|
# Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of
|
||||||
|
# stream._VK_HWSAFE / _VK_HDR / _VK_SDR10.
|
||||||
|
monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hwsafe(monkeypatch, safe):
|
||||||
|
# Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True →
|
||||||
|
# _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves).
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _opencl(monkeypatch, present):
|
||||||
|
# Pin the OpenCL-runtime probe so the routing doesn't depend on the CI box's /etc/OpenCL.
|
||||||
|
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
|
||||||
|
|
||||||
|
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, src=self.SRC)
|
||||||
|
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, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._hwsafe(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||||
|
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")
|
||||||
|
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch):
|
||||||
|
# A decodable HEVC that is true-HDR (PQ/HLG) can't use scale_vaapi=format=nv12 (Gen 9.5), and with
|
||||||
|
# no OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, False)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (False, False)
|
||||||
|
|
||||||
|
def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
|
||||||
|
# With the OpenCL runtime present, a true-HDR HEVC tone-maps on the GPU (VAAPI decode → OpenCL
|
||||||
|
# tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, True)
|
||||||
|
|
||||||
|
def test_auto_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch):
|
||||||
|
# F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone
|
||||||
|
# curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes
|
||||||
|
# software libx264 (an exact 10→8-bit downconvert), never (True, True).
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_SDR10)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (False, False)
|
||||||
|
|
||||||
|
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||||
|
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
|
||||||
|
# OpenCL is available.
|
||||||
|
self._policy(monkeypatch, "auto")
|
||||||
|
self._kind(monkeypatch, stream._VK_HWSAFE)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, 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, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
|
||||||
|
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
|
||||||
|
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
|
||||||
|
self._opencl(
|
||||||
|
monkeypatch, False
|
||||||
|
) # pin: tonemap-half is irrelevant here, keep it deterministic
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||||
|
assert (
|
||||||
|
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch):
|
||||||
|
# "on" trusts the GPU but must still route a true-HDR source through the tonemap graph (plain
|
||||||
|
# scale_vaapi would hit the VPP wall) when OpenCL is present.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, True)
|
||||||
|
|
||||||
|
def test_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||||
|
# F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must
|
||||||
|
# pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_SDR10)
|
||||||
|
self._opencl(monkeypatch, True)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, False)
|
||||||
|
|
||||||
|
def test_on_without_opencl_is_plain_hw(self, monkeypatch):
|
||||||
|
# "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime
|
||||||
|
# HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half
|
||||||
|
# of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw.
|
||||||
|
self._policy(monkeypatch, "on")
|
||||||
|
self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU
|
||||||
|
self._opencl(monkeypatch, False)
|
||||||
|
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||||
|
assert stream._select_encode(
|
||||||
|
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||||
|
) == (True, False)
|
||||||
|
|
||||||
|
|
||||||
class TestColdStartWarmup:
|
class TestColdStartWarmup:
|
||||||
@@ -190,7 +489,7 @@ class TestColdStartWarmup:
|
|||||||
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -203,7 +502,7 @@ class TestColdStartWarmup:
|
|||||||
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||||
|
|
||||||
@@ -217,7 +516,7 @@ class TestColdStartWarmup:
|
|||||||
# still returned so the client's own segment requests can drive it, as before.
|
# still returned so the client's own segment requests can drive it, as before.
|
||||||
waited = []
|
waited = []
|
||||||
|
|
||||||
def spy(path, timeout=20.0):
|
def spy(path, timeout=20.0, proc=None):
|
||||||
waited.append(path.name)
|
waited.append(path.name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -225,3 +524,347 @@ class TestColdStartWarmup:
|
|||||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0)
|
||||||
assert s is not None
|
assert s is not None
|
||||||
assert waited == ["seg_0.ts"]
|
assert waited == ["seg_0.ts"]
|
||||||
|
|
||||||
|
|
||||||
|
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, proc=None: 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, "_select_encode", lambda db, item, transcode, src: (False, 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, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
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 and s.tonemap is False
|
||||||
|
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||||
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "libx264" not in cmd
|
||||||
|
# Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd"
|
||||||
|
# would silently pass if the filter were dropped — assert it is actually present).
|
||||||
|
assert 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_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
|
||||||
|
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
|
||||||
|
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||||
|
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 and s.tonemap is True
|
||||||
|
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||||
|
assert _venc(cmd) == "h264_vaapi"
|
||||||
|
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||||
|
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
|
||||||
|
assert kwargs.get("preexec_fn") is None
|
||||||
|
|
||||||
|
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
|
||||||
|
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
|
||||||
|
# the item still plays and the route/player honestly show CPU.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_wait(path, timeout=20.0, proc=None):
|
||||||
|
seen["n"] += 1
|
||||||
|
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
|
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 False
|
||||||
|
) # honest CPU after the fallback
|
||||||
|
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
|
||||||
|
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||||
|
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
|
||||||
|
assert _venc(hw_cmd) == "h264_vaapi"
|
||||||
|
assert _venc(sw_cmd) == "libx264"
|
||||||
|
if os.name == "posix":
|
||||||
|
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
|
||||||
|
|
||||||
|
def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only
|
||||||
|
# the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that
|
||||||
|
# emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False,
|
||||||
|
# tonemap=False, so an HDR item still plays on CPU.
|
||||||
|
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def flaky_wait(path, timeout=20.0, proc=None):
|
||||||
|
seen["n"] += 1
|
||||||
|
return (
|
||||||
|
seen["n"] > 1
|
||||||
|
) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||||
|
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 False and s.tonemap is False
|
||||||
|
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
|
||||||
|
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||||
|
(sw_cmd, *_), _ = _CapturingProc.calls[1]
|
||||||
|
assert (
|
||||||
|
"tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1]
|
||||||
|
) # first attempt built the OpenCL graph
|
||||||
|
assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap
|
||||||
|
|
||||||
|
def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
|
||||||
|
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
|
||||||
|
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
|
||||||
|
# The identity guard must detect it's been superseded and yield the currently-registered session.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Newer: # stands in for the session a concurrent _spawn registered
|
||||||
|
hw = True
|
||||||
|
tonemap = False
|
||||||
|
transcode = True
|
||||||
|
last_access = 0.0
|
||||||
|
|
||||||
|
newer = _Newer()
|
||||||
|
|
||||||
|
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||||
|
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||||
|
# (now-killed) HW proc produced nothing.
|
||||||
|
stream._sessions["12345"] = newer
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
||||||
|
s = stream.start_session(
|
||||||
|
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||||
|
)
|
||||||
|
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||||
|
assert len(_CapturingProc.calls) == 1
|
||||||
|
assert s is newer # yielded the currently-registered session, did not clobber it
|
||||||
|
assert stream._sessions["12345"] is newer
|
||||||
|
|
||||||
|
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets
|
||||||
|
# _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not
|
||||||
|
# respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None
|
||||||
|
# is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields
|
||||||
|
# our own (now-dead) session instead, so the client's segment requests degrade to the retryable
|
||||||
|
# "not available yet" path.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||||
|
)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def wait_then_cull(path, timeout=20.0, proc=None):
|
||||||
|
captured["s"] = stream._sessions[
|
||||||
|
"12345"
|
||||||
|
] # the HW session the initial _spawn just registered
|
||||||
|
del stream._sessions[
|
||||||
|
"12345"
|
||||||
|
] # simulate _enforce_cap dropping the LRU under cap pressure
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
||||||
|
s = stream.start_session(
|
||||||
|
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
len(_CapturingProc.calls) == 1
|
||||||
|
) # no SW respawn — the cull is respected, cap not re-breached
|
||||||
|
assert (
|
||||||
|
s is captured["s"]
|
||||||
|
) # our own session yielded, NOT None (which the route would 404 as missing)
|
||||||
|
assert "12345" not in stream._sessions # the culled key stays gone
|
||||||
|
|
||||||
|
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, "_select_encode", lambda db, item, transcode, src: (False, 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
|
||||||
|
|
||||||
|
|
||||||
|
class TestProbeVideoKind:
|
||||||
|
"""The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI;
|
||||||
|
true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER
|
||||||
|
the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _probe(monkeypatch, stdout):
|
||||||
|
class _R:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
|
||||||
|
|
||||||
|
def test_8bit_sdr_is_hwsafe(self, monkeypatch):
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch):
|
||||||
|
# yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not
|
||||||
|
# tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent.
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||||
|
|
||||||
|
def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch):
|
||||||
|
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||||
|
|
||||||
|
def test_pq_transfer_is_hdr(self, monkeypatch):
|
||||||
|
# PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged.
|
||||||
|
self._probe(
|
||||||
|
monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n"
|
||||||
|
)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||||
|
|
||||||
|
def test_hlg_transfer_is_hdr(self, monkeypatch):
|
||||||
|
# HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR.
|
||||||
|
self._probe(
|
||||||
|
monkeypatch,
|
||||||
|
"pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n",
|
||||||
|
)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||||
|
|
||||||
|
def test_empty_probe_output_is_hwsafe(self, monkeypatch):
|
||||||
|
# A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime
|
||||||
|
# HW→SW fallback backstops a wrong guess.
|
||||||
|
self._probe(monkeypatch, "")
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
def test_probe_failure_assumes_hwsafe(self, monkeypatch):
|
||||||
|
# ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a
|
||||||
|
# probe miss costs at most one wasted HW attempt, never a stranded item.
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise OSError("no ffprobe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream.subprocess, "run", boom)
|
||||||
|
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenclPresent:
|
||||||
|
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
|
||||||
|
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
|
||||||
|
ICD path / glob / OSError guard would be caught (this routes every HDR source on prod)."""
|
||||||
|
|
||||||
|
def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||||
|
(tmp_path / "intel.icd").write_text("libintelocl.so\n")
|
||||||
|
assert stream._opencl_present() is True
|
||||||
|
|
||||||
|
def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch):
|
||||||
|
# A missing dir: Path.glob yields nothing (no raise) → False.
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope")
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
def test_false_when_glob_raises_oserror(self, monkeypatch):
|
||||||
|
# The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE,
|
||||||
|
# not return empty. That must be swallowed to False, never propagated up through _select_encode
|
||||||
|
# into start_session (which would crash HDR routing).
|
||||||
|
class _BoomDir:
|
||||||
|
def glob(self, pattern):
|
||||||
|
raise OSError("permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir())
|
||||||
|
assert stream._opencl_present() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForProcFastFail:
|
||||||
|
"""wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s
|
||||||
|
producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW
|
||||||
|
fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches
|
||||||
|
it wholesale, so this branch would otherwise never run)."""
|
||||||
|
|
||||||
|
class _DeadProc:
|
||||||
|
def poll(self):
|
||||||
|
return 1 # already exited non-zero
|
||||||
|
|
||||||
|
def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path):
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
seg.write_bytes(b"x")
|
||||||
|
# The proc exited but DID write a first segment → ready. (The final existence re-check.)
|
||||||
|
assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True
|
||||||
|
|
||||||
|
def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path):
|
||||||
|
# Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by
|
||||||
|
# using a long timeout the test would visibly hang on if the fast-fail branch regressed.
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||||
|
|
||||||
|
def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path):
|
||||||
|
# A zero-byte segment is not a real segment (size > 0 required).
|
||||||
|
seg = tmp_path / "seg_0.ts"
|
||||||
|
seg.write_bytes(b"")
|
||||||
|
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ guards that keep an upload out of paths that assume a re-fetchable source (edit
|
|||||||
|
|
||||||
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
|
||||||
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
|
||||||
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
|
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
|
||||||
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
|
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
|
||||||
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
|
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +18,7 @@ import pytest
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.downloads import mediakind, quota, storage
|
from app.downloads import mediakind, quota, storage
|
||||||
|
from app.downloads import probe as probemod
|
||||||
from app.downloads import upload as uploadmod
|
from app.downloads import upload as uploadmod
|
||||||
from app.models import DownloadQuota, MediaAsset
|
from app.models import DownloadQuota, MediaAsset
|
||||||
|
|
||||||
@@ -42,7 +43,9 @@ def _root(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def _probe(monkeypatch, result):
|
def _probe(monkeypatch, result):
|
||||||
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
|
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
|
||||||
|
# worker uses); patch it at that shared home.
|
||||||
|
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
|
||||||
|
|
||||||
|
|
||||||
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
|
||||||
@@ -325,16 +328,16 @@ def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, m
|
|||||||
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
|
||||||
|
|
||||||
|
|
||||||
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
|
# --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------
|
||||||
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
|
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
|
||||||
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
|
||||||
# regression in the classifier the force-download guarantee leans on is actually caught.
|
# regression in the classifier the force-download guarantee leans on is actually caught.
|
||||||
|
|
||||||
|
|
||||||
class TestProbeClassifier:
|
class TestProbeClassifier:
|
||||||
def _probe_with(self, monkeypatch, data):
|
def _probe_with(self, monkeypatch, data):
|
||||||
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||||
return uploadmod._probe(Path("x"))
|
return probemod.probe_media(Path("x"))
|
||||||
|
|
||||||
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
def test_probe_failure_is_a_generic_file(self, monkeypatch):
|
||||||
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
|
||||||
@@ -392,3 +395,109 @@ class TestProbeClassifier:
|
|||||||
}
|
}
|
||||||
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
v, a, w, h, d = self._probe_with(monkeypatch, data)
|
||||||
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
|
|
||||||
|
|
||||||
|
# --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) ---------
|
||||||
|
# The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict.
|
||||||
|
# yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those
|
||||||
|
# nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the
|
||||||
|
# probe is authoritative and `info` is only a gap-filler.
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveMediaFacts:
|
||||||
|
# These stub run_ffprobe_json (one level below probe_media) so the REAL probe parsing + the
|
||||||
|
# probe-vs-info precedence are exercised together — the precedence keys off whether ffprobe RAN.
|
||||||
|
@staticmethod
|
||||||
|
def _ffprobe(monkeypatch, data):
|
||||||
|
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
|
||||||
|
|
||||||
|
def test_probe_wins_when_info_lacks_codecs(self, monkeypatch):
|
||||||
|
# THE original regression: info has no vcodec/acodec/dims (Facebook), but the file probes as
|
||||||
|
# h264/aac.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a"},
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "codec_name": "h264", "width": 720, "height": 1280},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
|
||||||
|
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
|
||||||
|
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
|
||||||
|
|
||||||
|
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
|
||||||
|
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
|
||||||
|
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
|
||||||
|
# `info or probe` inversion would silently break.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mov,mp4,m4a"},
|
||||||
|
"streams": [
|
||||||
|
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
|
||||||
|
{"codec_type": "audio", "codec_name": "aac"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
|
||||||
|
)
|
||||||
|
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
|
||||||
|
|
||||||
|
def test_cover_art_audio_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
|
||||||
|
# THE bug this fix targets: an audio-mode download from a progressive source embeds cover art,
|
||||||
|
# and yt-dlp's info still carries the source's video codec. The probe correctly finds only audio
|
||||||
|
# (the attached_pic is skipped); info's vcodec must NOT resurrect a video track → media_kind
|
||||||
|
# stays audio, not a broken in-app 'video'.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "mp3", "duration": "180.0"},
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"codec_type": "video",
|
||||||
|
"codec_name": "mjpeg",
|
||||||
|
"disposition": {"attached_pic": 1},
|
||||||
|
},
|
||||||
|
{"codec_type": "audio", "codec_name": "mp3"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
|
||||||
|
)
|
||||||
|
assert v is None and a == "mp3" # info's avc1 did NOT win
|
||||||
|
assert mediakind.derive(v, a) == mediakind.AUDIO # NOT VIDEO
|
||||||
|
|
||||||
|
def test_still_image_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
|
||||||
|
# A downloaded still image: the probe returns no vcodec (image-format guard). info's image
|
||||||
|
# "vcodec" must not turn it into a broken 'video' → stays a generic file.
|
||||||
|
self._ffprobe(
|
||||||
|
monkeypatch,
|
||||||
|
{
|
||||||
|
"format": {"format_name": "png_pipe"},
|
||||||
|
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
|
||||||
|
)
|
||||||
|
assert v is None and mediakind.derive(v, a) == mediakind.FILE
|
||||||
|
|
||||||
|
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
|
||||||
|
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
|
||||||
|
self._ffprobe(monkeypatch, None)
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(
|
||||||
|
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
|
||||||
|
)
|
||||||
|
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
|
||||||
|
|
||||||
|
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
|
||||||
|
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
|
||||||
|
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
|
||||||
|
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
|
||||||
|
assert (v, a) == (None, "aac")
|
||||||
|
assert mediakind.derive(v, a) == mediakind.AUDIO
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import i18n from "../i18n";
|
|||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
|
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||||
|
|
||||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||||
@@ -253,6 +254,33 @@ function CrewPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
||||||
|
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
|
||||||
|
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
|
||||||
|
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!detail.playable) return null;
|
||||||
|
const cls = playableClass(detail.playable, detail.transcode_hw);
|
||||||
|
const sub = [
|
||||||
|
detail.codec_video ? detail.codec_video.toUpperCase() : null,
|
||||||
|
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
|
||||||
|
}`}
|
||||||
|
title={t(`plex.playable.${cls}`)}
|
||||||
|
>
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
|
||||||
|
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||||
|
{sub && <span className="opacity-70">{sub}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
detail: PlexItemDetail;
|
detail: PlexItemDetail;
|
||||||
variant: "page" | "overlay";
|
variant: "page" | "overlay";
|
||||||
@@ -425,7 +453,7 @@ export default function PlexInfo({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -479,6 +507,7 @@ export default function PlexInfo({
|
|||||||
{detail.studio}
|
{detail.studio}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
|
<PlaybackChip detail={detail} overlay={overlay} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
|
Gauge,
|
||||||
Info,
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Languages,
|
Languages,
|
||||||
@@ -38,8 +39,10 @@ import {
|
|||||||
subShadowCss,
|
subShadowCss,
|
||||||
type PlexPlayerPrefs,
|
type PlexPlayerPrefs,
|
||||||
} from "../lib/plexPlayerPrefs";
|
} from "../lib/plexPlayerPrefs";
|
||||||
import { Ctrl, PanelHead } from "./plexPlayerControls";
|
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||||
|
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||||
|
import { UpNextCard } from "./UpNextCard";
|
||||||
import {
|
import {
|
||||||
useAutoSkip,
|
useAutoSkip,
|
||||||
useControlsVisibility,
|
useControlsVisibility,
|
||||||
@@ -95,6 +98,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const aliveRef = useRef(true);
|
const aliveRef = useRef(true);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
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);
|
||||||
|
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
|
||||||
|
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||||
|
const tonemapRef = useRef<boolean | null>(null);
|
||||||
const durationRef = useRef(0);
|
const durationRef = useRef(0);
|
||||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
// ≥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.
|
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||||
@@ -114,6 +123,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
|
// 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).
|
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||||
const [buffering, setBuffering] = useState(false);
|
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);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
// 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
|
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||||
@@ -171,6 +184,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const infoOpenRef = useRef(false);
|
const infoOpenRef = useRef(false);
|
||||||
infoOpenRef.current = infoOpen;
|
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();
|
const now = useWallClock();
|
||||||
|
|
||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
@@ -186,6 +209,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setReady(false);
|
setReady(false);
|
||||||
setBuffering(false);
|
setBuffering(false);
|
||||||
setLoadError(null);
|
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;
|
let sess;
|
||||||
try {
|
try {
|
||||||
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
|
||||||
@@ -206,6 +233,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
);
|
);
|
||||||
return;
|
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;
|
||||||
|
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
|
||||||
|
setSessionIsTranscode(isTranscode);
|
||||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
// 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
|
// 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.
|
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
|
||||||
@@ -514,7 +547,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||||
if (absRef.current <= 0) return;
|
if (absRef.current <= 0) return;
|
||||||
api.plexSetState(id, "watched").catch(() => {});
|
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),
|
// Nothing queued next → dismiss the player back to the previous page (same as Stop/Escape),
|
||||||
// instead of stranding a black, ended <video> on screen.
|
// instead of stranding a black, ended <video> on screen.
|
||||||
else onClose();
|
else onClose();
|
||||||
@@ -523,6 +560,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
return () => video.removeEventListener("ended", onEnded);
|
return () => video.removeEventListener("ended", onEnded);
|
||||||
}, [id, nextId, go, onClose]);
|
}, [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
|
// "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
|
// 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
|
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
|
||||||
@@ -533,6 +578,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
|
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||||
else onClose();
|
else onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -618,6 +665,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
case "I":
|
case "I":
|
||||||
setInfoOpen((v) => !v);
|
setInfoOpen((v) => !v);
|
||||||
break;
|
break;
|
||||||
|
case "d":
|
||||||
|
case "D":
|
||||||
|
setStatsOpen((v) => !v);
|
||||||
|
break;
|
||||||
case "Backspace":
|
case "Backspace":
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -639,6 +690,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
setTracksOpen(false);
|
setTracksOpen(false);
|
||||||
} else if (helpOpenRef.current) setHelpOpen(false);
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
|
else if (statsOpenRef.current) setStatsOpen(false);
|
||||||
|
else if (upNextOpenRef.current) setUpNextOpen(false);
|
||||||
else if (!fullscreenElement()) onClose();
|
else if (!fullscreenElement()) onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -774,7 +827,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
|
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. */}
|
aria-live region so a screen reader distinguishes "buffering" from a frozen frame. */}
|
||||||
{!ready ? (
|
{!ready ? (
|
||||||
<span>{t("plex.player.loading")}</span>
|
<span>
|
||||||
|
{t(sessionIsTranscode ? "plex.player.preparing" : "plex.player.loading")}
|
||||||
|
</span>
|
||||||
) : buffering ? (
|
) : buffering ? (
|
||||||
<span className="sr-only">{t("plex.player.buffering")}</span>
|
<span className="sr-only">{t("plex.player.buffering")}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -785,9 +840,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
|
{/* 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. */}
|
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
|
||||||
{(menuOpen || tracksOpen || helpOpen || infoOpen || skipProgress != null) && (
|
{(menuOpen ||
|
||||||
<BackClose onBack={backIntent} />
|
tracksOpen ||
|
||||||
)}
|
helpOpen ||
|
||||||
|
infoOpen ||
|
||||||
|
statsOpen ||
|
||||||
|
upNextOpen ||
|
||||||
|
skipProgress != null) && <BackClose onBack={backIntent} />}
|
||||||
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div
|
<div
|
||||||
@@ -936,9 +995,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-white">
|
<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" />}
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||||
</Ctrl>
|
</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)}>
|
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||||
<RotateCcw className="w-5 h-5" />
|
<RotateCcw className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
@@ -1114,6 +1183,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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)}>
|
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||||
<Info className="w-5 h-5" />
|
<Info className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
@@ -1130,6 +1202,32 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
||||||
|
tonemapRef={tonemapRef}
|
||||||
|
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). */}
|
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
|
||||||
{helpOpen && (
|
{helpOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -1150,7 +1248,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setHelpOpen(false)}
|
onClick={() => setHelpOpen(false)}
|
||||||
className="p-1 rounded-lg hover:bg-white/10"
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1170,6 +1268,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
[["S"], "plex.player.keys.subtitle"],
|
[["S"], "plex.player.keys.subtitle"],
|
||||||
[["F"], "plex.player.keys.fullscreen"],
|
[["F"], "plex.player.keys.fullscreen"],
|
||||||
[["I"], "plex.player.keys.info"],
|
[["I"], "plex.player.keys.info"],
|
||||||
|
[["D"], "plex.player.keys.stats"],
|
||||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||||
[["H"], "plex.player.keys.help"],
|
[["H"], "plex.player.keys.help"],
|
||||||
] as [string[], string][]
|
] as [string[], string][]
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export function PlexPlayerSettings(props: {
|
|||||||
checked={prefs.autoSkipCredits}
|
checked={prefs.autoSkipCredits}
|
||||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.upNext.pref")}
|
||||||
|
checked={prefs.upNext}
|
||||||
|
onChange={(v) => patchPrefs({ upNext: v })}
|
||||||
|
/>
|
||||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||||
<Slider
|
<Slider
|
||||||
label={t("plex.player.autoSkipDelay")}
|
label={t("plex.player.autoSkipDelay")}
|
||||||
|
|||||||
@@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||||||
|
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||||||
|
...(scope === "movie" && facets && facets.playable.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "playable",
|
||||||
|
title: t("plex.filter.playable"),
|
||||||
|
body: (
|
||||||
|
<Combobox
|
||||||
|
label={t("plex.filter.playable")}
|
||||||
|
options={facets.playable.map((p) => ({
|
||||||
|
value: p.value,
|
||||||
|
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||||
|
count: p.count,
|
||||||
|
}))}
|
||||||
|
selected={filters.playable}
|
||||||
|
onChange={(next) => patch({ playable: next })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
tonemapRef,
|
||||||
|
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
|
||||||
|
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||||
|
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,
|
||||||
|
tonemap: tonemapRef.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, tonemapRef, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,14 +8,23 @@ import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "..
|
|||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||||
|
|
||||||
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
|
||||||
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
|
||||||
// all three stay visually distinct.
|
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
|
||||||
const PLAYABLE_TINT: Record<string, string> = {
|
// gradient; all four stay visually distinct in light + dark.
|
||||||
|
export const PLAYABLE_TINT: Record<string, string> = {
|
||||||
direct: "bg-success",
|
direct: "bg-success",
|
||||||
remux: "bg-warn",
|
remux: "bg-info",
|
||||||
transcode: "bg-danger",
|
transcode_hw: "bg-warn",
|
||||||
|
transcode_sw: "bg-danger",
|
||||||
};
|
};
|
||||||
|
// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display
|
||||||
|
// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys.
|
||||||
|
// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically.
|
||||||
|
export function playableClass(playable: string, transcodeHw?: boolean | null): string {
|
||||||
|
if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw";
|
||||||
|
return playable; // direct | remux
|
||||||
|
}
|
||||||
|
|
||||||
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||||
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||||
@@ -82,13 +91,22 @@ export function PlexPosterCard({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
|
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
|
||||||
{card.playable && (
|
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
|
||||||
<span
|
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
|
||||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
tooltip never fired on hover (a regression once the reveal overlay was added). */}
|
||||||
title={t(`plex.playable.${card.playable}`)}
|
{card.playable &&
|
||||||
/>
|
(() => {
|
||||||
)}
|
const cls = playableClass(card.playable, card.transcode_hw);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={t(`plex.playable.${cls}`)}
|
||||||
|
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
||||||
|
title={t(`plex.playable.${cls}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
||||||
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
||||||
{card.status === "watched" && (
|
{card.status === "watched" && (
|
||||||
|
|||||||
@@ -1,6 +1,49 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { RotateCcw } from "lucide-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 ------------------------------------------------------------
|
// --- settings-panel building blocks ------------------------------------------------------------
|
||||||
export function PanelHead({ children }: { children: ReactNode }) {
|
export function PanelHead({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
|
|||||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const p of filters.playable) {
|
||||||
|
out.push({
|
||||||
|
key: `playable:${p}`,
|
||||||
|
label: t(`plex.filter.playableOpt.${p}`),
|
||||||
|
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const k of filters.keywords) {
|
for (const k of filters.keywords) {
|
||||||
out.push({
|
out.push({
|
||||||
key: `keyword:${k}`,
|
key: `keyword:${k}`,
|
||||||
|
|||||||
@@ -166,7 +166,23 @@
|
|||||||
},
|
},
|
||||||
"plex_max_transcodes": {
|
"plex_max_transcodes": {
|
||||||
"label": "Max concurrent 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",
|
"save": "Save",
|
||||||
|
|||||||
@@ -104,6 +104,13 @@
|
|||||||
"collectionEmpty": "No collections",
|
"collectionEmpty": "No collections",
|
||||||
"matchLabel": "Match",
|
"matchLabel": "Match",
|
||||||
"studio": "Studio",
|
"studio": "Studio",
|
||||||
|
"playable": "Playback",
|
||||||
|
"playableOpt": {
|
||||||
|
"direct": "Direct Play",
|
||||||
|
"remux": "Direct Stream",
|
||||||
|
"transcode_hw": "Transcode · HW",
|
||||||
|
"transcode_sw": "Transcode · SW"
|
||||||
|
},
|
||||||
"moreRefine": "{{count}} more — refine with search"
|
"moreRefine": "{{count}} more — refine with search"
|
||||||
},
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
@@ -155,11 +162,15 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"buffering": "Buffering…",
|
"buffering": "Buffering…",
|
||||||
|
"preparing": "Preparing stream…",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"close": "Close",
|
||||||
"skipIntro": "Skip intro",
|
"skipIntro": "Skip intro",
|
||||||
"skipCredits": "Skip credits",
|
"skipCredits": "Skip credits",
|
||||||
"playPause": "Play / pause (space)",
|
"playPause": "Play / pause (space)",
|
||||||
"restart": "Restart from beginning",
|
"restart": "Restart from beginning",
|
||||||
|
"rewind10": "Back 10 seconds",
|
||||||
|
"forward10": "Forward 10 seconds",
|
||||||
"prev": "Previous episode",
|
"prev": "Previous episode",
|
||||||
"next": "Next episode",
|
"next": "Next episode",
|
||||||
"mute": "Mute (m)",
|
"mute": "Mute (m)",
|
||||||
@@ -187,7 +198,27 @@
|
|||||||
"help": "Show this help",
|
"help": "Show this help",
|
||||||
"audio": "Cycle audio track",
|
"audio": "Cycle audio track",
|
||||||
"subtitle": "Cycle subtitle",
|
"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",
|
"settings": "Settings",
|
||||||
"sync": "Offsets",
|
"sync": "Offsets",
|
||||||
@@ -261,8 +292,9 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
"remux": "Needs a light remux (no video re-encode)",
|
"remux": "Direct Stream — a light remux (no video re-encode)",
|
||||||
"transcode": "Needs transcoding (heavier)"
|
"transcode_hw": "Transcoded on the hardware encoder",
|
||||||
|
"transcode_sw": "Transcoded in software (heaviest)"
|
||||||
},
|
},
|
||||||
"playlistAdd": {
|
"playlistAdd": {
|
||||||
"manage": "Add to playlist",
|
"manage": "Add to playlist",
|
||||||
|
|||||||
@@ -166,7 +166,23 @@
|
|||||||
},
|
},
|
||||||
"plex_max_transcodes": {
|
"plex_max_transcodes": {
|
||||||
"label": "Max párhuzamos transcode",
|
"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",
|
"save": "Mentés",
|
||||||
|
|||||||
@@ -104,6 +104,13 @@
|
|||||||
"collectionEmpty": "Nincs kollekció",
|
"collectionEmpty": "Nincs kollekció",
|
||||||
"matchLabel": "Egyezés",
|
"matchLabel": "Egyezés",
|
||||||
"studio": "Stúdió",
|
"studio": "Stúdió",
|
||||||
|
"playable": "Lejátszás",
|
||||||
|
"playableOpt": {
|
||||||
|
"direct": "Közvetlen lejátszás",
|
||||||
|
"remux": "Közvetlen streamelés",
|
||||||
|
"transcode_hw": "Átkódolás · HW",
|
||||||
|
"transcode_sw": "Átkódolás · SW"
|
||||||
|
},
|
||||||
"moreRefine": "még {{count}} — finomíts kereséssel"
|
"moreRefine": "még {{count}} — finomíts kereséssel"
|
||||||
},
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
@@ -155,11 +162,15 @@
|
|||||||
"player": {
|
"player": {
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"buffering": "Pufferelés…",
|
"buffering": "Pufferelés…",
|
||||||
|
"preparing": "Stream előkészítése…",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
"close": "Bezárás",
|
||||||
"skipIntro": "Intro átugrása",
|
"skipIntro": "Intro átugrása",
|
||||||
"skipCredits": "Stáblista átugrása",
|
"skipCredits": "Stáblista átugrása",
|
||||||
"playPause": "Lejátszás / szünet (szóköz)",
|
"playPause": "Lejátszás / szünet (szóköz)",
|
||||||
"restart": "Újra az elejéről",
|
"restart": "Újra az elejéről",
|
||||||
|
"rewind10": "10 másodperc vissza",
|
||||||
|
"forward10": "10 másodperc előre",
|
||||||
"prev": "Előző rész",
|
"prev": "Előző rész",
|
||||||
"next": "Következő rész",
|
"next": "Következő rész",
|
||||||
"mute": "Némítás (m)",
|
"mute": "Némítás (m)",
|
||||||
@@ -187,7 +198,27 @@
|
|||||||
"help": "Súgó megjelenítése",
|
"help": "Súgó megjelenítése",
|
||||||
"audio": "Hangsáv váltása",
|
"audio": "Hangsáv váltása",
|
||||||
"subtitle": "Felirat 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",
|
"settings": "Beállítások",
|
||||||
"sync": "Eltolások",
|
"sync": "Eltolások",
|
||||||
@@ -261,8 +292,9 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
"remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)",
|
||||||
"transcode": "Transcode kell (nehezebb)"
|
"transcode_hw": "Hardveres kódolóval átkódolva",
|
||||||
|
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
|
||||||
},
|
},
|
||||||
"playlistAdd": {
|
"playlistAdd": {
|
||||||
"manage": "Listához adás",
|
"manage": "Listához adás",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
coercePlexFilters,
|
coercePlexFilters,
|
||||||
plexFiltersSignature,
|
plexFiltersSignature,
|
||||||
|
plexFiltersToParams,
|
||||||
|
parsePlexFilters,
|
||||||
|
plexFilterCount,
|
||||||
EMPTY_PLEX_FILTERS,
|
EMPTY_PLEX_FILTERS,
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
} from "./plex";
|
} from "./plex";
|
||||||
@@ -54,12 +57,14 @@ describe("plexFiltersSignature", () => {
|
|||||||
directors: [],
|
directors: [],
|
||||||
actors: [],
|
actors: [],
|
||||||
studios: [],
|
studios: [],
|
||||||
|
playable: [],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
collections: ["A"],
|
collections: ["A"],
|
||||||
};
|
};
|
||||||
const b: PlexFilters = {
|
const b: PlexFilters = {
|
||||||
collections: ["A"],
|
collections: ["A"],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
|
playable: [],
|
||||||
studios: [],
|
studios: [],
|
||||||
actors: [],
|
actors: [],
|
||||||
directors: [],
|
directors: [],
|
||||||
@@ -81,4 +86,28 @@ describe("plexFiltersSignature", () => {
|
|||||||
const b: PlexFilters = { ...base(), genres: ["Comedy"] };
|
const b: PlexFilters = { ...base(), genres: ["Comedy"] };
|
||||||
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
|
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("differs when the playback-class filter differs", () => {
|
||||||
|
const a = base();
|
||||||
|
const b: PlexFilters = { ...base(), playable: ["direct", "transcode_hw"] };
|
||||||
|
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playable filter round-trip + count", () => {
|
||||||
|
it("serializes playable as a CSV param and parses it back", () => {
|
||||||
|
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["direct", "transcode_sw"] };
|
||||||
|
const params = plexFiltersToParams(f);
|
||||||
|
expect(params.get("playable")).toBe("direct,transcode_sw");
|
||||||
|
expect(parsePlexFilters(params).playable).toEqual(["direct", "transcode_sw"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the playable param when empty", () => {
|
||||||
|
expect(plexFiltersToParams(EMPTY_PLEX_FILTERS).has("playable")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts each selected playback class like studios", () => {
|
||||||
|
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["remux", "transcode_hw"] };
|
||||||
|
expect(plexFilterCount(f)).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface PlexCard {
|
|||||||
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
|
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
|
||||||
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
|
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
|
||||||
playable?: string; // direct | remux | transcode
|
playable?: string; // direct | remux | transcode
|
||||||
|
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
|
||||||
status?: string; // new | watched | hidden
|
status?: string; // new | watched | hidden
|
||||||
position_seconds?: number;
|
position_seconds?: number;
|
||||||
season_count?: number | null; // show
|
season_count?: number | null; // show
|
||||||
@@ -139,6 +140,9 @@ export interface PlexItemDetail {
|
|||||||
year?: number | null;
|
year?: number | null;
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
playable: string; // direct | remux | transcode
|
playable: string; // direct | remux | transcode
|
||||||
|
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
|
||||||
|
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
|
||||||
|
video_bit_depth?: number | null; // e.g. 8 / 10 (info-chip subtitle)
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: string;
|
art: string;
|
||||||
library?: string | null; // Plex section key (for the admin collection editor)
|
library?: string | null; // Plex section key (for the admin collection editor)
|
||||||
@@ -253,6 +257,7 @@ export interface PlexFilters {
|
|||||||
actors: string[]; // OR (any) by default, or AND (all) via actorMode
|
actors: string[]; // OR (any) by default, or AND (all) via actorMode
|
||||||
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
||||||
studios: string[]; // OR — from any selected studio
|
studios: string[]; // OR — from any selected studio
|
||||||
|
playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only
|
||||||
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
||||||
collections: string[]; // Plex collection rating_keys; a title can be in several
|
collections: string[]; // Plex collection rating_keys; a title can be in several
|
||||||
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
|
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
|
||||||
@@ -264,6 +269,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
|||||||
directors: [],
|
directors: [],
|
||||||
actors: [],
|
actors: [],
|
||||||
studios: [],
|
studios: [],
|
||||||
|
playable: [],
|
||||||
keywords: [],
|
keywords: [],
|
||||||
collections: [],
|
collections: [],
|
||||||
};
|
};
|
||||||
@@ -309,6 +315,7 @@ export function plexFilterCount(f: PlexFilters): number {
|
|||||||
f.directors.length +
|
f.directors.length +
|
||||||
f.actors.length +
|
f.actors.length +
|
||||||
f.studios.length +
|
f.studios.length +
|
||||||
|
f.playable.length +
|
||||||
f.keywords.length +
|
f.keywords.length +
|
||||||
f.collections.length
|
f.collections.length
|
||||||
);
|
);
|
||||||
@@ -330,6 +337,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
|||||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||||
if (f.actorMode === "all") u.set("actor_mode", "all");
|
if (f.actorMode === "all") u.set("actor_mode", "all");
|
||||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||||
|
if (f.playable?.length) u.set("playable", f.playable.join(","));
|
||||||
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
|
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
|
||||||
if (f.collections?.length) u.set("collections", f.collections.join(","));
|
if (f.collections?.length) u.set("collections", f.collections.join(","));
|
||||||
if (f.collectionMode === "all") u.set("collection_mode", "all");
|
if (f.collectionMode === "all") u.set("collection_mode", "all");
|
||||||
@@ -371,6 +379,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters {
|
|||||||
f.actors = csv("actors");
|
f.actors = csv("actors");
|
||||||
if (p.get("actor_mode") === "all") f.actorMode = "all";
|
if (p.get("actor_mode") === "all") f.actorMode = "all";
|
||||||
f.studios = csv("studios");
|
f.studios = csv("studios");
|
||||||
|
f.playable = csv("playable");
|
||||||
f.keywords = csv("keywords");
|
f.keywords = csv("keywords");
|
||||||
f.collections = csv("collections");
|
f.collections = csv("collections");
|
||||||
if (p.get("collection_mode") === "all") f.collectionMode = "all";
|
if (p.get("collection_mode") === "all") f.collectionMode = "all";
|
||||||
@@ -380,6 +389,8 @@ export interface PlexFacets {
|
|||||||
genres: { value: string; count: number }[];
|
genres: { value: string; count: number }[];
|
||||||
content_ratings: { value: string; count: number }[];
|
content_ratings: { value: string; count: number }[];
|
||||||
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
|
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
|
||||||
|
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
|
||||||
|
playable: { value: string; count: number }[];
|
||||||
year_min: number | null;
|
year_min: number | null;
|
||||||
year_max: number | null;
|
year_max: number | null;
|
||||||
rating_max: number | null;
|
rating_max: number | null;
|
||||||
@@ -390,6 +401,9 @@ export interface PlexPlaySession {
|
|||||||
mode: "direct" | "hls";
|
mode: "direct" | "hls";
|
||||||
url: string;
|
url: string;
|
||||||
start_s: number;
|
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)
|
||||||
|
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
|
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type PlexPlayerPrefs = {
|
|||||||
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
|
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
|
||||||
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
|
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
|
||||||
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
|
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 = {
|
export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||||
volume: 1,
|
volume: 1,
|
||||||
@@ -71,6 +72,7 @@ export const DEFAULT_PREFS: PlexPlayerPrefs = {
|
|||||||
autoSkipIntro: false,
|
autoSkipIntro: false,
|
||||||
autoSkipCredits: false,
|
autoSkipCredits: false,
|
||||||
autoSkipDelay: 5,
|
autoSkipDelay: 5,
|
||||||
|
upNext: true,
|
||||||
};
|
};
|
||||||
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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("tags the mode row with HDR→SDR for a HW tonemap session", () => {
|
||||||
|
const rows = buildStatRows({ ...base, playable: "transcode", hwEncoder: true, tonemap: true });
|
||||||
|
expect(rows.find((r) => r.key === "mode")?.value).toBe("transcode · GPU · HDR→SDR");
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
// The HDR→SDR tag rides on the GPU encoder token for a tonemap session.
|
||||||
|
expect(fmtMode("transcode", true, true)).toBe("transcode · GPU · HDR→SDR");
|
||||||
|
expect(fmtMode("transcode", true, false)).toBe("transcode · GPU");
|
||||||
|
expect(fmtMode("transcode", null, true)).toBe("transcode"); // no encoder → no tag either
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// 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;
|
||||||
|
// For a HW transcode of a 10-bit/HDR source: true = the GPU OpenCL tonemap path (HDR→SDR) engaged, so
|
||||||
|
// the mode row can read "transcode · GPU · HDR→SDR". Absent/false for an 8-bit HW or software session.
|
||||||
|
tonemap?: 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 — and a
|
||||||
|
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
|
||||||
|
// HDR→SDR"). 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,
|
||||||
|
tonemap?: boolean | null,
|
||||||
|
): string {
|
||||||
|
const mode = playable || DASH;
|
||||||
|
if (playable !== "transcode" || hwEncoder == null) return mode;
|
||||||
|
const base = `${mode} · ${hwEncoder ? "GPU" : "CPU"}`;
|
||||||
|
return tonemap ? `${base} · HDR→SDR` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, s.tonemap),
|
||||||
|
},
|
||||||
|
{ 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;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const RICH_FILTERS: PlexFilters = {
|
|||||||
actors: ["Bale", "Caine"],
|
actors: ["Bale", "Caine"],
|
||||||
actorMode: "all",
|
actorMode: "all",
|
||||||
studios: ["Warner"],
|
studios: ["Warner"],
|
||||||
|
playable: ["direct", "transcode_hw"],
|
||||||
keywords: ["heist"],
|
keywords: ["heist"],
|
||||||
collections: ["1234"],
|
collections: ["1234"],
|
||||||
collectionMode: "all",
|
collectionMode: "all",
|
||||||
|
|||||||
@@ -14,6 +14,64 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.78.3",
|
||||||
|
date: "2026-08-30",
|
||||||
|
summary:
|
||||||
|
"A new Plex filter: narrow the movie library by how each title plays — Direct Play, Direct Stream, or Transcode (hardware or software). See at a glance which titles are light on the server and which need a re-encode.",
|
||||||
|
features: [
|
||||||
|
"Filter movies by playback class in the sidebar: Direct Play (plays as-is), Direct Stream (a light remux), Transcode · HW (re-encoded on the GPU) and Transcode · SW (re-encoded on the CPU — the heaviest). Pick several to combine them. A small coloured dot on each movie card shows its class at a glance.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"The class is derived at sync from the codec, then refined by a bounded background probe that reads each transcode title's real bit depth/HDR — so a 10-bit HEVC that needs the heavier software path is labelled correctly. Went through several multi-agent deep-review rounds (probe-freshness on file replacement, facet self-exclusion, HU/EN parity, an accessibility label on the class dot) — re-reviewed until a round came back clean.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.78.2",
|
||||||
|
date: "2026-08-30",
|
||||||
|
summary:
|
||||||
|
"HDR and 10-bit videos in the Plex player now transcode on the server GPU instead of falling back to the CPU. HDR is tone-mapped to SDR on the GPU so the picture looks right in the browser, at a fraction of the CPU cost.",
|
||||||
|
features: [
|
||||||
|
"A 10-bit / HDR video (HDR10, HLG — common on 4K and modern HEVC files) that needs transcoding now runs on the Intel GPU, tone-mapping HDR→SDR on-device so colours look right in the browser and the CPU is barely touched. Previously these fell back to a heavy software encode; now the GPU does the work. If the GPU path can't start for any reason, playback still falls back to software automatically, so a video never fails to play.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"A 10-bit video that is NOT HDR (standard-range 10-bit, common in anime and some HEVC encodes) is no longer run through the HDR tone-curve — it gets a plain down-convert, so its colours are preserved exactly instead of being subtly darkened.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"The transcode pipeline now decides hardware vs software (and HDR tone-mapping) from a real probe of the source, and the GPU HDR path uses the Intel OpenCL runtime baked into the image. The whole change went through several multi-agent deep-review rounds — including an adversarially-verified fix to a start/seek race in the session fallback — re-reviewed until a round came back clean.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.78.1",
|
||||||
|
date: "2026-08-30",
|
||||||
|
summary:
|
||||||
|
"A fix for how downloads and uploads are recognised: an audio file with embedded cover art (or a still image) is no longer mistaken for a video, so it gets the right player and the right controls.",
|
||||||
|
fixes: [
|
||||||
|
"A downloaded audio track that carries album art — and some files from sites that report a video stream that isn't really there — were sometimes classified as video, giving a Play button that opened a broken, picture-less player. Siftlode now decides what a finished file actually is by inspecting the file itself, so audio stays audio, images stay files, and only real videos get the video player.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"The media-kind classifier now trusts the on-disk probe over the source site's metadata whenever the probe could read the file, so stale metadata can't resurrect a video track the file doesn't have. Went through multi-agent deep-review, findings fixed and re-reviewed until clean.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
version: "0.76.0",
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
|
|||||||
Reference in New Issue
Block a user