fix(plex): subtitles stop blocking the request, and stop re-reading the file
Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.
Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:
full pass, one track 154 s
full pass, both tracks in ONE run 111 s
-c:s copy (no WebVTT conversion) 143 s
from 1:00:00 to the end of file 66 s
a 630 s window at 1:00:00, both 8 s
So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:
* a window around the playhead is extracted and published FIRST (seconds), and
the full pass completes behind it;
* every embedded text track rides that same single read — picking Hungarian used
to cost a full pass and then switching to English cost another one;
* none of it happens inside a request any more. The endpoint answers 202 while a
background worker extracts, the player polls /status (carrying its position, so
the window follows the viewer), and one ffmpeg run finally has a timeout.
-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.
The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.
Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.
Verified in the browser against the real Plex library, and every new test
mutation-checked.
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { createFragMeter } from "../lib/plexStats";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import {
|
||||
useAutoSkip,
|
||||
@@ -114,6 +115,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// 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);
|
||||
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
|
||||
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
|
||||
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
|
||||
const fragMeterRef = useRef(createFragMeter());
|
||||
// ≥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.
|
||||
const multiAudioRef = useRef(false);
|
||||
@@ -303,6 +308,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// 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;
|
||||
// Measure the real delivered bitrate: bytes per second of MEDIA, per fragment. A new session
|
||||
// is a new stream (different start offset, possibly a different audio rendition), so the
|
||||
// rolling window starts empty rather than carrying the previous session's numbers over.
|
||||
fragMeterRef.current.reset();
|
||||
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
|
||||
fragMeterRef.current.add({
|
||||
type: data.frag.type,
|
||||
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
|
||||
durationS: data.frag.duration,
|
||||
});
|
||||
});
|
||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||
hls.attachMedia(video);
|
||||
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
|
||||
@@ -361,6 +377,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
textSubs,
|
||||
selectedSub,
|
||||
subTrackOffset,
|
||||
subShowable,
|
||||
subFail,
|
||||
subGen,
|
||||
onSubTrackError,
|
||||
} = usePlexTracks({
|
||||
id,
|
||||
detail,
|
||||
@@ -851,15 +871,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
>
|
||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
||||
{selectedSub && (
|
||||
{selectedSub && subShowable && (
|
||||
<track
|
||||
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
|
||||
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
|
||||
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
|
||||
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch, and by the
|
||||
// extraction generation so a widened track replaces the first short window.
|
||||
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}-${subGen}`}
|
||||
kind="subtitles"
|
||||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
|
||||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
|
||||
srcLang={selectedSub.language ?? undefined}
|
||||
label={selectedSub.label}
|
||||
onError={onSubTrackError}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
@@ -894,6 +916,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
|
||||
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
|
||||
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
|
||||
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
|
||||
item 8). It sits one rung above the track-switch OSD so the two never overlap. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{selectedSub && !subShowable && (
|
||||
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||||
{subFail ? (
|
||||
<span className="text-danger">{t(`plex.player.subUnavailable.${subFail}`)}</span>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||||
the whole point is confirming something you cannot otherwise see.
|
||||
@@ -1279,6 +1324,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<PlexStatsOverlay
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
fragMeterRef={fragMeterRef}
|
||||
audioLabel={
|
||||
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
|
||||
}
|
||||
subLabel={
|
||||
subOrd == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
|
||||
}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
tonemapRef={tonemapRef}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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";
|
||||
import { buildStatRows, type FragMeter, 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
|
||||
@@ -11,6 +11,9 @@ import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
@@ -21,6 +24,11 @@ export function PlexStatsOverlay({
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
|
||||
// long as this panel) — the source of the MEASURED bitrate row.
|
||||
fragMeterRef: RefObject<FragMeter>;
|
||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||
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)
|
||||
@@ -88,6 +96,9 @@ export function PlexStatsOverlay({
|
||||
totalFrames: total,
|
||||
bufferAheadS: bufferAhead,
|
||||
hlsLevel: level,
|
||||
measuredBps: fragMeterRef.current?.bitrate(),
|
||||
audioLabel,
|
||||
subLabel,
|
||||
sessionStartS: sessionStartRef.current ?? 0,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
@@ -97,7 +108,19 @@ export function PlexStatsOverlay({
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
|
||||
}, [
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
"codec": "Codec",
|
||||
"bitrate": "Bitrate",
|
||||
"level": "HLS level",
|
||||
"audio": "Audio track",
|
||||
"subtitle": "Subtitle track",
|
||||
"frames": "Dropped frames",
|
||||
"buffer": "Buffer ahead",
|
||||
"offset": "Session offset",
|
||||
@@ -214,6 +216,12 @@
|
||||
"fineSeek": "Fine seek (Ctrl)",
|
||||
"autoHide": "Auto-hide controls",
|
||||
"subStyle": "Subtitles",
|
||||
"subPreparing": "Preparing subtitles: {{label}}…",
|
||||
"subUnavailable": {
|
||||
"bitmap": "This subtitle is image-based and can't be shown as text.",
|
||||
"missing": "This subtitle track isn't available.",
|
||||
"failed": "This subtitle couldn't be loaded."
|
||||
},
|
||||
"subSize": "Text size",
|
||||
"subPos": "Position",
|
||||
"subColor": "Text color",
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
"codec": "Kodek",
|
||||
"bitrate": "Bitráta",
|
||||
"level": "HLS szint",
|
||||
"audio": "Hangsáv",
|
||||
"subtitle": "Feliratsáv",
|
||||
"frames": "Eldobott képkockák",
|
||||
"buffer": "Puffer előre",
|
||||
"offset": "Munkamenet-eltolás",
|
||||
@@ -214,6 +216,12 @@
|
||||
"fineSeek": "Finom léptetés (Ctrl)",
|
||||
"autoHide": "Vezérlők elrejtése",
|
||||
"subStyle": "Feliratok",
|
||||
"subPreparing": "Felirat előkészítése: {{label}}…",
|
||||
"subUnavailable": {
|
||||
"bitmap": "Ez a felirat képalapú, szövegként nem jeleníthető meg.",
|
||||
"missing": "Ez a feliratsáv nem érhető el.",
|
||||
"failed": "Ezt a feliratot nem sikerült betölteni."
|
||||
},
|
||||
"subSize": "Szövegméret",
|
||||
"subPos": "Pozíció",
|
||||
"subColor": "Szövegszín",
|
||||
|
||||
@@ -633,8 +633,32 @@ export const plexApi = {
|
||||
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
|
||||
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
|
||||
plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
|
||||
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
|
||||
// `gen` is the cached track's generation: extraction publishes a window around the playhead first
|
||||
// and widens it in the background, so the URL has to change when it grows or the browser would keep
|
||||
// serving the first, short VTT from its own cache.
|
||||
plexSubtitleUrl: (id: string, ord: number, offset = 0, gen = 0): string => {
|
||||
const p = new URLSearchParams();
|
||||
if (offset) p.set("offset", String(offset));
|
||||
if (gen) p.set("gen", String(gen));
|
||||
const q = p.toString();
|
||||
return `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${q ? `?${q}` : ""}`;
|
||||
},
|
||||
// Whether a subtitle track can be shown yet. Extracting one out of a big MKV costs a full read of
|
||||
// the container (measured: 154 s for a 17 GB film), so the server does it in the background — around
|
||||
// `at`, the viewer's position, first — and the player polls this instead of mounting a <track> that
|
||||
// would sit empty for minutes. `partial` = a window around `at` is ready and still growing.
|
||||
plexSubtitleStatus: (
|
||||
id: string,
|
||||
ord: number,
|
||||
at = 0,
|
||||
): Promise<{
|
||||
state: "ready" | "partial" | "preparing" | "failed";
|
||||
code: string | null;
|
||||
gen: number;
|
||||
}> =>
|
||||
req(
|
||||
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}/status?at=${Math.max(0, Math.floor(at))}`,
|
||||
),
|
||||
// `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the
|
||||
// periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a
|
||||
// final checkpoint, so a single watch doesn't spray Plex with timeline updates.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A subtitle <track> must not be mounted before the server has something to put in it.
|
||||
//
|
||||
// Bug hunt 2026-09, item 8: extracting one track out of a big MKV is a full read of the container
|
||||
// (154 s for a 17 GB film, measured on prod), and it used to happen inside the <track>'s own
|
||||
// request. The browser gives a <track> no visible state, so the player showed nothing at all —
|
||||
// which reads as "switching subtitles does nothing", and the reported workaround was to stop/start
|
||||
// until the cache happened to be warm. The extraction now runs in the background, publishing a
|
||||
// window around the playhead first, and this hook's poll is what decides when there is something to
|
||||
// mount and what to say while there isn't.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRef, type ReactNode, type MutableRefObject } from "react";
|
||||
import { usePlexTracks } from "./plexPlayerHooks";
|
||||
import { api, type PlexItemDetail } from "./api";
|
||||
import type Hls from "hls.js";
|
||||
|
||||
const RK = "98614";
|
||||
|
||||
const detail = (): PlexItemDetail =>
|
||||
({
|
||||
id: RK,
|
||||
duration_seconds: 8580,
|
||||
audio_streams: [{ ord: 0, label: "Magyar AC3 5.1", language: "hu", default: true }],
|
||||
subtitle_streams: [
|
||||
{ ord: 0, label: "Magyar", language: "hu", text: true },
|
||||
{ ord: 1, label: "English", language: "en", text: true },
|
||||
],
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
function mount(qc: QueryClient) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(
|
||||
() =>
|
||||
usePlexTracks({
|
||||
id: RK,
|
||||
detail: detail(),
|
||||
sessionK: 0,
|
||||
subOffset: 0,
|
||||
subPos: 88,
|
||||
loadSession: async () => {},
|
||||
patchPrefs: () => {},
|
||||
videoRef: createRef<HTMLVideoElement>(),
|
||||
hlsRef: createRef<Hls>(),
|
||||
multiAudioRef: { current: false },
|
||||
audioRef: { current: 0 } as MutableRefObject<number | null>,
|
||||
aoffRef: { current: 0 },
|
||||
absRef: { current: 3600 },
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
|
||||
describe("usePlexTracks — the <track> waits for the extraction", () => {
|
||||
// vitest runs with globals off, so testing-library's auto-cleanup never registers: unmount by
|
||||
// hand or the hook's 250 ms sub-offset debounce fires into a torn-down environment.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("is not showable while the track is still being extracted", async () => {
|
||||
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
||||
state: "preparing",
|
||||
code: null,
|
||||
gen: 0,
|
||||
});
|
||||
const { result } = mount(client());
|
||||
result.current.setSubOrd(0);
|
||||
await waitFor(() => expect(api.plexSubtitleStatus).toHaveBeenCalled());
|
||||
await waitFor(() => expect(result.current.selectedSub?.ord).toBe(0));
|
||||
// The selected track exists and is offered in the picker — it just cannot be rendered yet, and
|
||||
// the caller must be told so rather than mounting an empty <track>.
|
||||
expect(result.current.subShowable).toBe(false);
|
||||
expect(result.current.subFail).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the viewer's position, so the extraction windows around where they are", async () => {
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const { result } = mount(client());
|
||||
result.current.setSubOrd(1);
|
||||
await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600));
|
||||
});
|
||||
|
||||
it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => {
|
||||
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
||||
state: "partial",
|
||||
code: null,
|
||||
gen: 1757500000000000000,
|
||||
});
|
||||
const { result } = mount(client());
|
||||
result.current.setSubOrd(0);
|
||||
await waitFor(() => expect(result.current.subShowable).toBe(true));
|
||||
// The generation goes into the <track> key/URL: without it the browser would keep serving the
|
||||
// first, short window from its own cache once the background pass widens the track.
|
||||
expect(result.current.subGen).toBe(1757500000000000000);
|
||||
});
|
||||
|
||||
it("reports a track that can never be shown, in the bucket the player has a sentence for", async () => {
|
||||
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
||||
state: "failed",
|
||||
code: "bitmap",
|
||||
gen: 0,
|
||||
});
|
||||
const { result } = mount(client());
|
||||
result.current.setSubOrd(0);
|
||||
await waitFor(() => expect(result.current.subFail).toBe("bitmap"));
|
||||
expect(result.current.subShowable).toBe(false);
|
||||
});
|
||||
|
||||
it("folds the server's finer failure codes into 'missing'", async () => {
|
||||
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({
|
||||
state: "failed",
|
||||
code: "no_file",
|
||||
gen: 0,
|
||||
});
|
||||
const { result } = mount(client());
|
||||
result.current.setSubOrd(0);
|
||||
await waitFor(() => expect(result.current.subFail).toBe("missing"));
|
||||
});
|
||||
|
||||
it("asks nothing at all while subtitles are off", async () => {
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "ready", code: null, gen: 1 });
|
||||
const { result } = mount(client());
|
||||
await waitFor(() => expect(result.current.textSubs).toHaveLength(2));
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
expect(result.current.subShowable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type RefObject,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import Hls from "hls.js";
|
||||
import {
|
||||
exitFullscreen,
|
||||
@@ -344,6 +344,18 @@ export function useAutoSkip(opts: {
|
||||
// stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
|
||||
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
|
||||
// component and passed in so this hook and the media session read/write the same substrate.
|
||||
/** Why a subtitle track can't be shown, in the three buckets the player has a sentence for. The
|
||||
* server's codes are finer-grained than that on purpose (they drive the HTTP status too), so the
|
||||
* fold happens here rather than multiplying near-identical translations. */
|
||||
export type SubFailReason = "bitmap" | "missing" | "failed";
|
||||
|
||||
function subFailReason(code: string | null | undefined): SubFailReason {
|
||||
if (code === "bitmap") return "bitmap";
|
||||
if (code === "no_item" || code === "no_track" || code === "no_file" || code === "not_fetchable")
|
||||
return "missing";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
export function usePlexTracks(opts: {
|
||||
id: string;
|
||||
detail: PlexItemDetail | undefined;
|
||||
@@ -374,9 +386,19 @@ export function usePlexTracks(opts: {
|
||||
textSubs: PlexItemDetail["subtitle_streams"];
|
||||
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
|
||||
subTrackOffset: number;
|
||||
/** The selected track has something to render (a window around the playhead, or the whole thing).
|
||||
* False means "not yet" — the caller must NOT mount a <track>, it would just sit blank. */
|
||||
subShowable: boolean;
|
||||
/** The selected track can never be shown, bucketed for the message the player puts on screen. */
|
||||
subFail: SubFailReason | null;
|
||||
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
|
||||
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
|
||||
subGen: number;
|
||||
onSubTrackError: () => void;
|
||||
} {
|
||||
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
|
||||
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
|
||||
const qc = useQueryClient();
|
||||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||||
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||||
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||||
@@ -455,6 +477,46 @@ export function usePlexTracks(opts: {
|
||||
[patchPrefs, loadSession, aoffRef, absRef],
|
||||
);
|
||||
|
||||
// --- is the selected track actually extractable yet? -------------------------------------------
|
||||
// Pulling one subtitle track out of a big MKV means ffmpeg streams the whole container: measured on
|
||||
// prod at 154 s for a 17 GB film (and the same again for the second track). That used to happen
|
||||
// INSIDE the <track>'s own request, so the browser sat on an empty track with no spinner, no retry
|
||||
// and no error — the "switch the track, nothing happens, stop/start a few times and eventually it
|
||||
// works" report (bug hunt 2026-09, item 8). The server now extracts in the background, a window
|
||||
// around the playhead first, and this poll is what tells us when there is something to mount.
|
||||
const subStatusQ = useQuery({
|
||||
queryKey: qk.plexSubtitle(id, subOrd ?? -1),
|
||||
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, absRef.current ?? 0),
|
||||
enabled: subOrd != null,
|
||||
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
|
||||
// position, so seeking past the extracted window is what asks for the next one. A settled
|
||||
// answer — the whole track, or a reason it can never be shown — stops the poll; without that
|
||||
// stop, a bitmap track would poll for the length of the film AND keep re-arming the extraction
|
||||
// every time the server's short failure memo expired.
|
||||
refetchInterval: (q) => {
|
||||
const st = q.state.data?.state;
|
||||
if (st === "ready" || st === "failed") return false;
|
||||
return st === "partial" ? 2000 : 1000;
|
||||
},
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
const subStatus = subOrd == null ? null : (subStatusQ.data ?? null);
|
||||
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
|
||||
// viewer needs right now, and the poll widens it underneath them.
|
||||
const subShowable = subStatus?.state === "ready" || subStatus?.state === "partial";
|
||||
const subFail: SubFailReason | null =
|
||||
subStatusQ.isError && subOrd != null
|
||||
? "failed"
|
||||
: subStatus?.state === "failed"
|
||||
? subFailReason(subStatus.code)
|
||||
: null;
|
||||
// A <track> that errors anyway (the cached VTT was evicted between the poll and the fetch) goes
|
||||
// back to polling rather than staying silently blank.
|
||||
const onSubTrackError = useCallback(() => {
|
||||
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
|
||||
}, [qc, id, subOrd]);
|
||||
|
||||
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
|
||||
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
|
||||
@@ -521,5 +583,9 @@ export function usePlexTracks(opts: {
|
||||
textSubs,
|
||||
selectedSub,
|
||||
subTrackOffset,
|
||||
subShowable,
|
||||
subFail,
|
||||
subGen: subStatus?.gen ?? 0,
|
||||
onSubTrackError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildStatRows,
|
||||
createFragMeter,
|
||||
fmtBitrate,
|
||||
fmtBufferSecs,
|
||||
fmtClock,
|
||||
@@ -89,6 +90,8 @@ describe("buildStatRows", () => {
|
||||
"codec",
|
||||
"bitrate",
|
||||
"level",
|
||||
"audio",
|
||||
"subtitle",
|
||||
"frames",
|
||||
"buffer",
|
||||
"offset",
|
||||
@@ -144,3 +147,96 @@ describe("fmtMode", () => {
|
||||
expect(fmtMode("transcode", null, true)).toBe("transcode"); // no encoder → no tag either
|
||||
});
|
||||
});
|
||||
|
||||
// --- item 12 of the 2026-09 bug hunt ------------------------------------------------------------
|
||||
//
|
||||
// The panel reported "Bitrate: 211 kbps" on EVERY remux, which the viewer rightly refused to
|
||||
// believe. It was reading the master playlist's BANDWIDTH, and for a remux ffmpeg computes that from
|
||||
// the only stream whose rate it knows — the `-b:a 192k` AAC track — plus 10%: 192000 × 1.1 = 211200,
|
||||
// a constant that has nothing to do with the video. And the panel never said which audio or subtitle
|
||||
// track was playing, though the player has switched both since E5.
|
||||
|
||||
describe("measured bitrate", () => {
|
||||
it("reports bytes per second of MEDIA, not of wall clock", () => {
|
||||
const m = createFragMeter();
|
||||
// A 6 s remux segment of 6 MB is 8 Mbps, however fast it happened to download.
|
||||
m.add({ type: "main", bytes: 6_000_000, durationS: 6 });
|
||||
expect(m.bitrate()).toBe(8_000_000);
|
||||
});
|
||||
|
||||
it("adds the audio rendition's rate instead of averaging it away", () => {
|
||||
// Multi-audio sessions ship audio as its own rendition, so both fragment types cover the SAME
|
||||
// media seconds. Pooling bytes over pooled durations would report half the real bitrate.
|
||||
const m = createFragMeter();
|
||||
m.add({ type: "main", bytes: 6_000_000, durationS: 6 });
|
||||
m.add({ type: "audio", bytes: 150_000, durationS: 6 });
|
||||
expect(m.bitrate()).toBe(8_000_000 + 200_000);
|
||||
});
|
||||
|
||||
it("has nothing to say before the first fragment, and after a reset", () => {
|
||||
const m = createFragMeter();
|
||||
expect(m.bitrate()).toBeUndefined(); // an honest dash — never the manifest's stand-in number
|
||||
m.add({ type: "main", bytes: 6_000_000, durationS: 6 });
|
||||
m.reset();
|
||||
expect(m.bitrate()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores init segments and anything with no media duration", () => {
|
||||
const m = createFragMeter();
|
||||
m.add({ type: "main", bytes: 900, durationS: 0 });
|
||||
m.add({ type: "main", bytes: 0, durationS: 6 });
|
||||
expect(m.bitrate()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the stream: old fragments fall out of the rolling window", () => {
|
||||
const m = createFragMeter(2);
|
||||
m.add({ type: "main", bytes: 60_000_000, durationS: 6 }); // a dense scene
|
||||
m.add({ type: "main", bytes: 3_000_000, durationS: 6 });
|
||||
m.add({ type: "main", bytes: 3_000_000, durationS: 6 });
|
||||
expect(m.bitrate()).toBe(4_000_000); // the 80 Mbps sample is gone, not still weighing it down
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStatRows — what is actually playing", () => {
|
||||
const hls = (over: Partial<PlexStatsInput> = {}): PlexStatsInput => ({
|
||||
playable: "remux",
|
||||
transport: "hls",
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
droppedFrames: 6,
|
||||
totalFrames: 1458,
|
||||
bufferAheadS: 12,
|
||||
hlsLevel: { index: 0, total: 1, bitrate: 211_200 },
|
||||
sessionStartS: 0,
|
||||
abs: 10,
|
||||
duration: 8580,
|
||||
...over,
|
||||
});
|
||||
const value = (rows: { key: string; value: string }[], key: string) =>
|
||||
rows.find((r) => r.key === key)?.value;
|
||||
|
||||
it("shows the MEASURED bitrate and never the manifest's 211 kbps stand-in", () => {
|
||||
const rows = buildStatRows(hls({ measuredBps: 7_900_000 }));
|
||||
expect(value(rows, "bitrate")).toBe("7.9 Mbps");
|
||||
});
|
||||
|
||||
it("dashes the bitrate until something has been measured", () => {
|
||||
// The level still carries its 211200 BANDWIDTH here — using it would be the old bug.
|
||||
expect(value(buildStatRows(hls()), "bitrate")).toBe(DASH);
|
||||
});
|
||||
|
||||
it("names the selected audio and subtitle track", () => {
|
||||
const rows = buildStatRows(hls({ audioLabel: "Magyar AC3 5.1", subLabel: "Magyar" }));
|
||||
expect(value(rows, "audio")).toBe("Magyar AC3 5.1");
|
||||
expect(value(rows, "subtitle")).toBe("Magyar");
|
||||
});
|
||||
|
||||
it("keeps the track rows for direct play too, where there is no HLS level at all", () => {
|
||||
const rows = buildStatRows(
|
||||
hls({ transport: "direct", hlsLevel: null, audioLabel: "English", subLabel: "Off" }),
|
||||
);
|
||||
expect(rows.some((r) => r.key === "bitrate")).toBe(false); // HLS-only, as before
|
||||
expect(value(rows, "audio")).toBe("English");
|
||||
expect(value(rows, "subtitle")).toBe("Off");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface PlexStatsInput {
|
||||
totalFrames: number; // getVideoPlaybackQuality().totalVideoFrames
|
||||
bufferAheadS: number; // seconds buffered ahead of the playhead
|
||||
hlsLevel: HlsLevelInfo | null; // null for direct play / Safari-native HLS
|
||||
// MEASURED stream bitrate: bytes actually delivered per second of media, from hls.js's per-fragment
|
||||
// load stats. Undefined until the first fragment lands (an honest dash — never a stand-in number).
|
||||
// This row used to read the master playlist's BANDWIDTH, which for a remux session ffmpeg derives
|
||||
// from the one thing it knows, the `-b:a 192k` AAC track, plus 10%: a constant 211 kbps on every
|
||||
// remux regardless of the actual video. That is what the viewer correctly refused to believe.
|
||||
measuredBps?: number;
|
||||
audioLabel?: string; // the selected audio track, as the tracks menu names it
|
||||
subLabel?: string; // the selected subtitle track (or the menu's "Off" entry)
|
||||
sessionStartS: number; // media-clock offset the current session was opened at
|
||||
abs: number; // current media time (absolute, session offset already folded in)
|
||||
duration: number;
|
||||
@@ -138,12 +146,16 @@ export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
||||
{
|
||||
key: "bitrate",
|
||||
labelKey: "plex.player.stats.bitrate",
|
||||
value: fmtBitrate(s.hlsLevel?.bitrate),
|
||||
value: fmtBitrate(s.measuredBps),
|
||||
},
|
||||
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
|
||||
);
|
||||
}
|
||||
rows.push(
|
||||
// Which audio/subtitle track is actually playing. The player has switched both since E5 and the
|
||||
// panel never said which one you landed on — the one question "stats for nerds" was being asked.
|
||||
{ key: "audio", labelKey: "plex.player.stats.audio", value: s.audioLabel || DASH },
|
||||
{ key: "subtitle", labelKey: "plex.player.stats.subtitle", value: s.subLabel || DASH },
|
||||
{
|
||||
key: "frames",
|
||||
labelKey: "plex.player.stats.frames",
|
||||
@@ -159,3 +171,49 @@ export function buildStatRows(s: PlexStatsInput): StatRow[] {
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// --- measured bitrate ---------------------------------------------------------------------------
|
||||
|
||||
interface FragSample {
|
||||
type: string; // hls.js fragment type: "main" (video, and muxed audio) / "audio" / "subtitle"
|
||||
bytes: number;
|
||||
durationS: number; // MEDIA seconds the fragment carries (not how long it took to download)
|
||||
}
|
||||
|
||||
export interface FragMeter {
|
||||
add(sample: FragSample): void;
|
||||
/** Bits per second of media, or undefined while nothing has been measured yet. */
|
||||
bitrate(): number | undefined;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/** Rolling meter over hls.js FRAG_LOADED stats: bytes delivered per second of MEDIA, which is the
|
||||
* stream's real bitrate — as opposed to download throughput, which only measures the network.
|
||||
*
|
||||
* Kept per fragment TYPE and summed: in a multi-audio session the audio ships as its own rendition,
|
||||
* so video and audio fragments cover the SAME media seconds. Pooling their bytes over the pooled
|
||||
* durations would report half the truth; per-type rates add up to the whole. */
|
||||
export function createFragMeter(window = 10): FragMeter {
|
||||
const byType = new Map<string, FragSample[]>();
|
||||
return {
|
||||
add(sample) {
|
||||
if (!(sample.bytes > 0) || !(sample.durationS > 0)) return; // pre-roll / init segments
|
||||
const list = byType.get(sample.type) ?? [];
|
||||
list.push(sample);
|
||||
if (list.length > window) list.shift();
|
||||
byType.set(sample.type, list);
|
||||
},
|
||||
bitrate() {
|
||||
let total = 0;
|
||||
for (const list of byType.values()) {
|
||||
const bytes = list.reduce((n, f) => n + f.bytes, 0);
|
||||
const secs = list.reduce((n, f) => n + f.durationS, 0);
|
||||
if (secs > 0) total += (bytes * 8) / secs;
|
||||
}
|
||||
return total > 0 ? total : undefined;
|
||||
},
|
||||
reset() {
|
||||
byType.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ export const qk = {
|
||||
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
|
||||
: (["plex-show"] as const),
|
||||
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
|
||||
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
|
||||
// that steers the background extraction, not part of the track's identity.
|
||||
plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const,
|
||||
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
|
||||
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
|
||||
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
|
||||
|
||||
Reference in New Issue
Block a user