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:
2026-09-10 19:31:05 +02:00
parent b8bff504d8
commit ed4ab4e13c
13 changed files with 1158 additions and 66 deletions
@@ -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);
});
});