Files
siftlode/frontend/src/lib/plexStats.test.ts
T
peter ed4ab4e13c 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.
2026-09-10 19:31:05 +02:00

243 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildStatRows,
createFragMeter,
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",
"audio",
"subtitle",
"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
});
});
// --- 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");
});
});