Compare commits

..
31 Commits
Author SHA1 Message Date
peter 602f62967a release: 0.78.4 (bug/plex-transcode-hls-startposition)
gate / frontend (push) Failing after 1m55s
gate / backend (push) Successful in 2m57s
2026-08-30 20:33:15 +02:00
peter ba00b50dea release: 0.78.4 — fix Plex transcode playback stall/rebuffer
hls.js startPosition:0 so the transcode/remux HLS session starts VOD-style
at the requested position instead of chasing the live edge of the growing
EVENT playlist.
2026-08-30 20:32:56 +02:00
peter 92adb593c8 fix(plex): start hls.js at position 0 for the transcode/remux HLS session
Our HLS is a growing EVENT playlist with no ENDLIST, which hls.js reads as
LIVE. Its default startPosition (-1) then starts at the live edge and hugs it
(no forward buffer → rebuffer spinners), and on a resume/seek session the
segments' absolute PTS (-copyts, offset by the server's -ss) don't line up
with the live-edge timeline → 0 fragments load → permanent stall. The
seek-restart model makes fragment 0 the requested content position, so start
VOD-style at 0 and buffer forward. Fixes the transcode playback stall/rebuffer
found in 0.78.2 UAT.
2026-08-30 20:09:00 +02:00
peter 66132b6dda release: 0.78.3 (feature/plex-playback-class-filter)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s
2026-08-30 19:09:22 +02:00
peter d09f021f0c release: 0.78.3 — filter the Plex feed by playback class 2026-08-30 19:09:08 +02:00
peter d7e9240e54 merge main (0.78.2) into playback-class before release
# Conflicts:
#	backend/app/plex/stream.py
2026-08-30 19:08:08 +02:00
peter f2fa49b778 release: 0.78.2 (feature/plex-hdr-hw-tonemap)
gate / frontend (push) Failing after 1m37s
gate / backend (push) Successful in 2m24s
2026-08-30 18:56:32 +02:00
peter d4ed45b2eb release: 0.78.2 — HW HDR→SDR tonemap for 10-bit/HDR Plex transcode 2026-08-30 18:56:18 +02:00
peter 375f6e58be merge main (0.78.1) into tonemap before release 2026-08-30 18:55:14 +02:00
peter 1a102ce81a release: 0.78.1 (bug/download-mediakind-probe)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m22s
2026-08-30 18:47:05 +02:00
peter bfd45a155e release: 0.78.1 — download media_kind from a file probe (BUG 2) 2026-08-30 18:46:33 +02:00
peter 1102ad6a28 fix(downloads): make the probe verdict authoritative over yt-dlp info by probe-ran, not per-field
Deep-review round 1 on bug/download-mediakind-probe found a real HIGH the prior
review missed:

- resolve_media_facts used a per-field `probe or info` fallback, so a probe that
  RAN and deliberately found no video track (cover-art-only audio: attached_pic
  skipped; a still image: format-guarded) had its None video overridden by
  yt-dlp's stale vcodec. An audio-mode download from a progressive source (info
  carries the source's video codec, embed-thumbnail on) was then classified VIDEO
  -> a Play button + a broken in-app <video> with no video track. Only the
  download path regressed (upload calls probe_media directly).

  Fix: gate precedence on whether ffprobe RAN, not per-field. If the probe read
  the file, its codec verdict wins outright (a None means 'no such track'); only a
  full ffprobe failure falls back to info. Dimensions still gap-fill from info.
  Refactored the parsing into _facts_from_probe so resolve_media_facts can see the
  raw probe-ran signal; probe_media keeps its 5-tuple (upload + TestProbeClassifier
  unchanged).

- tests: the cover-art-audio and still-image resurrection cases, a differing
  populated-info precedence case (the prior-flagged gap), and the ffprobe-failed
  info fallback — now stubbing run_ffprobe_json so the real parsing + precedence
  run together.

Backend gate green (ruff + full pytest).
2026-08-30 18:09:26 +02:00
peter c1a3b7b935 fix(plex): gate the part-key re-probe to single-version items; drop dead i18n key; cover cross-facet narrowing
Deep-review round 2 on the round-1 fixes (all low):

- part_key churn (state): round 1's part-key reset re-probed EVERY sync for a
  multi-version item (an original + a Plex 'Optimized' copy), because
  _media_facts reads media[0]/part[0] whose order Plex doesn't guarantee stable,
  oscillating transcode_hw between the two versions. Gate the part-key signal on
  a single-version item (len(Media)<=1); codec-change reset still applies to all.
  A multi-version same-codec re-encode not self-healing is an acceptable rare
  edge; a per-sync re-probe loop is not.

- dead i18n key: this diff's playableClass() collapses to transcode_hw/sw, so the
  bare plex.playable.transcode key it orphaned is now unused (knip can't see
  dynamic keys). Dropped from both locales, parity kept.

- test: cross-facet narrowing by an active playable class (a co-facet's year
  range collapses to the selected subset) — the flip side of self-exclusion,
  previously unpinned.

The aria-label+title double-announcement on the card dot is a deliberate accepted
trade-off (reliable SR name + mouse tooltip). Backend + FE gates green.
2026-08-30 17:58:38 +02:00
peter bf98b48517 fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter:

- Sticky watermark (medium): _apply_item reset the bit-depth probe watermark
  only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC)
  kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes
  media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the
  file was replaced), not just the codec.

- i18n parity (medium): the HU playback-class labels were mixed-language (only
  'direct' translated). Translated remux/transcode_hw/transcode_sw and the
  tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas).

- Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into
  routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared
  source of truth for facet order + zero-fill).

- a11y (low): the browse-card playback-class dot conveyed meaning only via a
  mouse-hover title; added role=img + aria-label so SR/touch users get the class.

- tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet
  self-exclusion under an active playable filter, the migration codec-set ==
  HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order,
  the transient probe-failure retry path, and the same-codec re-encode re-probe.

Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/
vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the
known merge-order constraint — this branch's runtime is codec-only; the facet
becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left
as a sequencing constraint, not code-changed here.
2026-08-30 17:51:33 +02:00
peter b563155a37 docs(plex): correct the cull-yield fallback comment to name the real route 404s
Deep-review round 3: the only surviving finding — a comment-drift nit. The
fallback's cull-yield comment named the route's 'Not available yet' path, but a
culled-key segment request actually returns 'No active playback session' (the
substantive invariant — never a None that 404s as file-not-found — is unchanged).
Reworded to match the route. Concurrency + correctness came back clean.
2026-08-30 15:18:26 +02:00
peter 915319fa35 fix(plex): make the HW->SW fallback supersede-check atomic; honest cull yield
Deep-review round 2 on the round-1 fix:

- Race (medium): the round-1 identity guard did the supersede check and the
  respawn in TWO separate _lock sections. A newer same-key session installed in
  the gap was still torn down by _spawn's unconditional pop+kill (the round-1
  comment even had the direction backwards). Fold the check into _spawn via a
  new replace_only= param: the identity test and the pop+replace now share ONE
  lock, so the respawn is a true no-op when superseded/culled.

- Honest states (low): the superseded branch returned current_session(key),
  which is None when the key was cap-culled during our out-of-lock wait; the
  route turns None into a dishonest 'Media file not found on disk' 404 for a
  file that exists. Yield 'current_session(key) or s' so a culled session
  degrades to the route's retryable 'not available yet' path instead.

- Hygiene: _want_hw docstring corrected (it is a test-only shim now; production
  decides via _select_encode) and the two stale _want_hw references refreshed
  (stream.py header, config.py plex_transcode_hw).

- tests: the cap-cull no-respawn/no-None sub-case; the _opencl_present except
  OSError branch (glob raising, not just empty); tightened a tautological -vf
  assertion. Backend gate green (ruff + full pytest).
2026-08-30 15:11:37 +02:00
peter a2a8f5681d fix(plex): don't tonemap 10-bit SDR; identity-guard the HW→SW fallback
Deep-review round 1 on feature/plex-hdr-hw-tonemap:

- F1 (correctness): _select_encode gated tonemap on bit-depth alone, so a
  10-bit *SDR* (bt709) source got the HDR hable tone curve applied, visibly
  altering an SDR picture where only a 10->8-bit downconvert was needed. Split
  the ffprobe gate into a 3-way classifier (_probe_video_kind: hwsafe / hdr /
  sdr10); only true-HDR (PQ/HLG) takes the OpenCL tonemap graph, 10-bit SDR
  drops to software libx264 (an exact downconvert). Applies to both auto and on.

- F2 (race): the runtime HW->SW fallback popped+killed _sessions[key] with no
  identity check. Because wait_for blocks OUTSIDE _lock, a concurrent same-key
  _spawn (rapid seek-restart) or _enforce_cap cull could kill this session; the
  fallback then respawned software over the NEWER session and breached the
  transcode cap. Guard the respawn: only fall back if _sessions[key] is still
  this session, else yield the currently-registered one.

- tests: 3-way classifier (incl. HLG + empty-probe), real _opencl_present glob,
  real wait_for proc fast-fail branch, tonemap-variant fallback, the F1
  regressions (auto + on), the supersede/no-respawn race, and the on/opencl
  matrix cell. Backend gate green (ruff + full pytest).
2026-08-30 15:02:41 +02:00
peter 4827aebb7f feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's
behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable,
HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in
the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on
the prod iGPU at ~4.2x realtime, ~1 CPU core.

- stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable
  source picks the tonemap graph when the OpenCL runtime is present, else drops
  to software. `_opencl_present` gates on a registered ICD. `_venc_opts` /
  `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices,
  `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept
  as the hw-half wrapper. The runtime HW→SW fallback still backstops a session
  that emits no segment.
- Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed
  from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5;
  the .deb runs on the trixie base — verified live). Harmless without /dev/dri.
- route + FE: thread `tonemap` through the session response; the stats overlay
  mode row reads "transcode · GPU · HDR→SDR" for the tonemap path.
- tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on ×
  8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
2026-08-30 06:02:45 +02:00
peter 840526b1b8 feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how
they play — the groundwork for collecting HW-transcodable (or no-transcode) versions.

Four classes derived from the stored `playable` + a new `transcode_hw` facet:
- Direct Play (direct), Direct Stream (remux)
- Transcode · HW  — playable=transcode AND the iGPU can hardware-transcode it
- Transcode · SW  — playable=transcode, software libx264 only

The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the
VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW.

Backend:
- New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at.
  Migration backfills transcode_hw codec-only so the filter works immediately.
- `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves
  to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the
  runtime encoder choice can't disagree by codec.
- Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode
  items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded
  per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe).
- Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show
  is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON.

Frontend:
- Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active
  chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip
  with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity.
- Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal
  overlay swallowed the cursor; the dot now sits above it (z-20).

Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd
on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
2026-08-30 04:45:11 +02:00
peter 10d69cb316 fix(plex): route 10-bit/HDR HEVC to software + runtime VAAPI→libx264 fallback
A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12
on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments
and the item never played, with no fallback — a retry just re-picked the failing HW
path. An 8-bit HEVC and the same file on libx264 both work.

Two defenses:
- `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new
  `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from
  color_transfer) — a 10-bit/HDR source takes the software libx264 path, which
  downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this
  is probed at session start. "on" still trusts the admin and skips the gate.
- `start_session` adds a runtime HW→SW fallback: a HW session that emits no first
  segment respawns on libx264 and reports hw=False (the route/stats overlay honestly
  show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead
  of burning the 25s budget before the fallback fires.

Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto
gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in
TestTranscodeStartSession. Backend lane green.
2026-08-30 03:53:31 +02:00
peter a86131e062 fix(downloads): classify media_kind from a file probe, not yt-dlp info
A downloaded video was silently demoted to a download-only "file" (no
in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which
it does for Facebook and other sources. Finalize derived media_kind from
those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4
became non-playable in the library.

Fix: probe the finished file on disk (the ground truth) at finalize and
derive from that, falling back to the info dict only for a field the probe
couldn't read. This is the same ffprobe the upload ingest already used, now
extracted to a shared downloads/probe.py so the download worker and the
upload path share one classifier (and can't drift). Also backfills the
stored vcodec/acodec/width/height from the real file.

Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook
no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to
the shared module.
2026-08-30 03:32:14 +02:00
peter a84f2db310 release: 0.78.0 (feature/e6-plex-player-polish)
gate / frontend (push) Failing after 1m35s
gate / backend (push) Successful in 2m20s
2026-08-30 02:26:25 +02:00
peter 872d91f540 release: 0.78.0 — Plex transcode playback + player polish (E6) 2026-08-30 02:25:20 +02:00
peter 4eaa68ceeb feat(plex): S1a — add intel-media-va-driver for VAAPI transcode (E6)
The runtime image ships ffmpeg with h264_vaapi/libva2 but no VA driver, so
h264_vaapi can't initialise and a Plex transcode silently falls back to
libx264. Add the iHD driver (intel-media-va-driver) so the UHD 630 iGPU can
hardware-encode once /dev/dri is passed into LXC 118 (host + compose side,
tracked in siftlode-ops). Harmless where no render node is present.
2026-08-30 02:14:06 +02:00
peter 00539a4d97 fix(plex): deep-review follow-ups on E6 transcode + player polish
Eight verified findings from the sprint deep-review:

- Transcode cap: _enforce_cap now counts only transcode sessions, so a
  live remux (stream-copy) stream can no longer be LRU-evicted mid-play —
  matching the config comment + the HU/EN Config hint that promise remux
  is uncapped.
- Player Back: the browser/mouse Back interceptor (BackClose) now also
  mounts for the stats overlay and up-next card, so Back closes them first
  instead of ejecting the whole player (keyboard/Esc already did).
- Honest cold-start: loadSession clears sessionIsTranscode up front, so a
  transcode->direct/remux switch no longer flashes a stale "Preparing
  stream..." during the session request.
- Stats overlay a11y: the close (X) buttons in the stats, help and info
  overlays now read as "Close"/"Bezaras" (new plex.player.close key), not
  "Back"; dropped aria-live from the per-second stat grid so a screen
  reader is not flooded with telemetry.

Tests: route-level transcode routing (transcode item -> HLS re-encode
session, remux stays copy, direct opens none); start_session threading the
preset/crf/max_height knobs + hw/preexec into the ffmpeg argv; UpNextCard
countdown auto-advance, Play-now, Cancel and the S?-E? heading fallback.

Also brings UpNextCard.tsx / plexStats.ts / plexStats.test.ts to prettier.
2026-08-30 02:04:17 +02:00
peter dc24d353e0 feat(plex): honest transcode cold-start + encoder in stats (E6 S1d)
Surface the transcode session's runtime facts in the player. The session
response's transcode/hw flags now thread through: the stats overlay's mode row
reads "transcode · GPU" (VAAPI) or "transcode · CPU" (libx264) — the at-a-glance
confirmation of which S1 path engaged — and the initial-load overlay says
"Preparing stream…" instead of "Loading…" for a transcode session, since its
first segment is genuinely slower than a remux and shouldn't read as a hang.

fmtMode (pure, unit-tested) appends the encoder only for a transcode with a known
encoder. i18n HU/EN for the new preparing label. Browser-UAT'd on the XviD
fixture: mode row reads "transcode · CPU" live (software, no render node here).
2026-08-30 01:30:53 +02:00
peter ce96032edb feat(plex): transcode resource discipline + admin knobs (E6 S1c)
The software-fallback re-encode now runs deprioritized and admin-configurable.
The SW transcode ffmpeg child is niced (preexec_fn → os.nice, applied per-child
because the Popen lives in the long-lived API process, which must NOT itself be
niced — unlike the download worker's whole-process _deprioritize) so a CPU-bound
re-encode can't starve request handling or playback; the VAAPI path needs none of
it.

New sysconfig knobs (plex group, rendered on the admin Configuration page, i18n
HU/EN): plex_transcode_hw (auto/on/off, layered onto _want_hw), plex_transcode_
preset, plex_transcode_crf, plex_transcode_max_height (software-only downscale-to
cap; the GPU path encodes at source resolution). plex_max_transcodes stays the
concurrency cap (comment corrected — the fallback is shipped, not "later").

pytest extends the command-builder + _want_hw coverage (preset/crf/max_height
plumbing, the on/off/auto policy). Browser-UAT'd: max_height=240 drops the live
resolution to 330x240; the SW ffmpeg child runs at nice 10 (ppid=api process).
2026-08-30 01:22:30 +02:00
peter 0658f9550b feat(plex): transcode playback mode — VAAPI/libx264 re-encode (E6 S1b)
Non-h264 items (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) now play by
re-encoding the video to H.264 inside the existing HLS session, instead of the
old HTTP 501. VAAPI hardware encode (h264_vaapi, full GPU decode→scale→encode
pipeline) where the render node is present and the source is HW-decodable;
libx264 software fallback otherwise (yuv420p 8-bit, -threads reserve-one-core).

The re-encode mints a keyframe at every segment boundary (-force_key_frames) so
the HLS muxer still cuts independent segments and seeking works; everything else
— -copyts, the HLS tail, multi-audio renditions, the AAC audio path — is the
same machinery as remux, so hls.js/subtitles/seeking are unchanged. The session
route returns transcode/hw so the player can surface the active mode.

pytest pins both command branches (software + VAAPI shape) and the HW-selection
gate (render node present AND source HW-decodable). Browser-UAT'd on the XviD
fixture (Winning London): 501→plays, real 528×384 H.264 frames, 0 dropped, seek
lands clean; stats overlay Mode row reads "transcode".
2026-08-30 01:06:08 +02:00
peter f162788135 feat(plex): up-next end-card at episode end (E6 S4)
At episode end, when a next item exists and the pref is on, show an up-next
card (poster + title + ~10s countdown + Cancel) instead of the old instant jump,
so binge stays in the viewer's control (D6-A). Countdown or Play now advances;
Cancel/✕ dismisses and stays on the finished frame. Lives only on the
play-to-the-end path — credits-auto-skip advances earlier and never reaches the
card, so the two reconcile by construction (no double-advance). New pref
'upNext' (default on) with a Playback-tab toggle; the card closes on item change
and joins the Back/Escape cascade. i18n HU/EN.
2026-08-30 00:28:50 +02:00
peter 968674bc6a feat(plex): stats-for-nerds debug overlay (E6 S2)
Add a toggleable stats overlay to the Plex player: active mode
(direct/remux/transcode), transport, resolution, codec, bitrate, HLS level,
dropped frames, buffer-ahead, session offset and position — sampled once a
second off the live videoRef + hlsRef (no data layer). Non-modal corner panel,
aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or
its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles
as the S1 transcode-verification surface. i18n HU/EN.
2026-08-30 00:17:34 +02:00
peter 90ae9f6e88 feat(plex): on-screen ±10s skip buttons flanking play/pause (E6 S3)
Add discoverable rewind-10 / forward-10 controls to the Plex player control
bar, flanking play/pause (D5-A). New Skip10 glyph: an outline circular arrow
with a centred '10', deliberately distinct from the episode-level
SkipBack/SkipForward and from Restart's plain RotateCcw. Reuses seekTo; mirrors
the keyboard ←/→ (seekStep=10). i18n HU/EN aria-labels.
2026-08-30 00:09:36 +02:00
43 changed files with 3383 additions and 178 deletions
+20 -4
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
0.76.0 0.78.4
@@ -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
View File
@@ -258,9 +258,22 @@ class Settings(BaseSettings):
# that catches what the incremental history feed can't — notably an un-watch done on the Plex # 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
+3 -3
View File
@@ -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")
+8 -9
View File
@@ -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
+107
View File
@@ -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
+2 -63
View File
@@ -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,
+16
View File
@@ -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)
+2
View File
@@ -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,
} }
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+42
View File
@@ -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,
+4
View File
@@ -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),
+12 -8
View File
@@ -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,
} }
+8
View File
@@ -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 ---
+1 -1
View File
@@ -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."""
+495
View File
@@ -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
+107
View File
@@ -0,0 +1,107 @@
"""The playback-session route's mode routing (E6 S1b).
`POST /stream/{rk}/session` is the single switch that decides how an item plays: a browser-native
file is served raw (`mode:"direct"`), everything else (re)starts an HLS session — and a
`playable="transcode"` item (non-h264: HEVC/VP9/AV1/…) must take that HLS path with the video
RE-ENCODED, not stream-copied. That routing replaced a former `HTTP 501`, and the only thing that
tells `start_session` to re-encode is the `transcode=` flag this route derives from `it.playable`.
The command-builder itself is unit-tested in `test_plex_stream.py`; here we pin the ROUTE decision
(no ffmpeg spawned — `start_session` is stubbed to echo the flag it was handed).
DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from types import SimpleNamespace
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.plex import paths as plex_paths
from app.plex import stream as plex_stream
def _item(db, *, playable, rating_key="m1"):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
it = PlexItem(
rating_key=rating_key,
library_id=lib.id,
kind="movie",
title="A Movie",
file_path="/media/a.mkv",
playable=playable,
)
db.add(it)
db.commit()
return it
def _user(db, email="play@example.com"):
u = User(email=email)
db.add(u)
db.flush()
return u
def _post(db, user, monkeypatch, rating_key, *, captured):
"""Drive the endpoint with ffmpeg stubbed out. `start_session` records the `transcode` flag it
was called with into `captured` and returns a stand-in session echoing it back."""
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
monkeypatch.setattr(plex_stream, "reap_idle", lambda: None)
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode
return SimpleNamespace(
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, 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
+647 -4
View File
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
second half of this file pins what keeps it off a LIVE session's segments.""" 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
+117 -8
View File
@@ -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
+30 -1
View File
@@ -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. */}
+116 -9
View File
@@ -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.
@@ -228,7 +261,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef.current = null; hlsRef.current = null;
} }
if (sess.mode === "hls" && Hls.isSupported()) { if (sess.mode === "hls" && Hls.isSupported()) {
const hls = new Hls({ maxBufferLength: 30, enableWorker: true }); // startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts
// playback at the live edge and hugs it, so it never builds a forward buffer (rebuffer
// spinners), and on a resume/seek session the segments' absolute PTS (-copyts, offset by the
// server's -ss) don't line up with the live-edge timeline → 0 fragments load → permanent
// stall. But the seek-restart model means fragment 0 IS the requested content position
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls; hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`); hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video); hls.attachMedia(video);
@@ -514,7 +555,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 +568,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 +586,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 +673,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 +698,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 +835,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 +848,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 +1003,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 +1191,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 +1210,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 +1256,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 +1276,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")}
+22
View File
@@ -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();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
// Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD
// of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then
// advances; "Play now" advances immediately; Cancel/✕ dismisses and stays on the finished frame. It only
// lives on the play-to-the-end path; credits-auto-skip advances earlier and never reaches this card.
const COUNTDOWN_S = 10;
export function UpNextCard({
nextId,
onAdvance,
onCancel,
}: {
nextId: string;
onAdvance: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const lang = plexLang();
const nextQ = useQuery({
queryKey: qk.plexItem(nextId, lang),
queryFn: () => api.plexItem(nextId, lang),
});
const next = nextQ.data;
// Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
const advanceRef = useRef(onAdvance);
advanceRef.current = onAdvance;
useEffect(() => {
const started = performance.now();
const iv = window.setInterval(() => {
const left = COUNTDOWN_S - (performance.now() - started) / 1000;
if (left <= 0) {
window.clearInterval(iv);
setRemaining(0);
advanceRef.current();
} else {
setRemaining(left);
}
}, 100);
return () => window.clearInterval(iv);
}, []);
const secs = Math.ceil(remaining);
const frac = remaining / COUNTDOWN_S; // 1 → 0 as it counts down
const R = 10;
const C = 2 * Math.PI * R;
// Title: "Show · S2·E5" over the episode title for episodes; the plain title for movies/queue items.
const heading =
next?.kind === "episode" && next.show_title
? `${next.show_title} · S${next.season_number ?? "?"}·E${next.episode_number ?? "?"}`
: (next?.title ?? "");
const sub = next?.kind === "episode" ? next.title : null;
return (
<div
role="region"
aria-label={t("plex.player.upNext.title")}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-24 right-6 z-menu flex w-[22rem] max-w-[calc(100vw-3rem)] gap-3 rounded-2xl border border-white/15 bg-neutral-900/95 p-3.5 text-white shadow-2xl"
>
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
alt=""
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="mb-0.5 text-2xs font-semibold uppercase tracking-wide text-white/50">
{t("plex.player.upNext.title")}
</div>
<div className="truncate text-sm font-semibold leading-tight">{heading || "…"}</div>
{sub && <div className="truncate text-xs text-white/60">{sub}</div>}
<div className="mt-auto flex items-center gap-2 pt-2.5">
<button
onClick={onAdvance}
className="flex items-center gap-1.5 rounded-full bg-accent px-3 py-1.5 text-xs font-semibold text-black hover:brightness-110"
>
<span className="relative grid h-[22px] w-[22px] flex-none place-items-center">
<svg viewBox="0 0 24 24" className="absolute inset-0 -rotate-90">
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="rgba(0,0,0,0.25)"
strokeWidth="2"
/>
<circle
cx="12"
cy="12"
r={R}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C * (1 - frac)}
/>
</svg>
<Play className="h-2.5 w-2.5 fill-black" />
</span>
{t("plex.player.upNext.playNow")}
<span className="tabular-nums">{secs}</span>
</button>
<button
onClick={onCancel}
className="rounded-full border border-white/25 px-3 py-1.5 text-xs text-white/85 hover:bg-white/10"
>
{t("plex.player.upNext.cancel")}
</button>
</div>
</div>
<button
onClick={onCancel}
aria-label={t("plex.player.upNext.cancel")}
className="absolute right-2 top-2 rounded p-0.5 text-white/50 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
+31 -13
View File
@@ -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}`,
+17 -1
View File
@@ -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",
+35 -3
View File
@@ -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",
+17 -1
View File
@@ -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",
+35 -3
View File
@@ -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",
+29
View File
@@ -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);
});
}); });
+14
View File
@@ -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
+2
View File
@@ -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));
+146
View File
@@ -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
});
});
+161
View File
@@ -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",
+70
View File
@@ -14,6 +14,76 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.78.4",
date: "2026-08-30",
summary:
"A fix for stuttering and stalling when the Plex player transcodes a video — playback now starts cleanly and keeps a healthy buffer, whether you play from the start or resume/seek partway through.",
fixes: [
"A transcoded Plex video (any format your browser can't play natively) no longer rebuffers every second, and resuming or seeking into one no longer leaves it stuck on “Loading…” forever. The player now starts the stream at the position you asked for and buffers ahead, instead of chasing the live edge of an in-progress transcode.",
],
chores: [
"The player builds its HLS client to start at the requested position (the transcode session already begins encoding there), which fixes both the every-second rebuffer and the resume/seek stall in one change. Verified against the HLS library's live-vs-VOD start logic and put through a multi-agent deep-review (correctness, player behaviour, test coverage) that came back clean.",
],
},
{
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",