fix(plex): a playlist that never answers is asked for again; the slow line reads this stream
A playlist timeout was resumed in the same session, but hls.js reloads no manifest on startLoad: no request, no error, a spinner for good. It is now asked for again like a server away. An absence also ended only on a fragment, which native HLS never reports, and a viewer trying again after the error started with the spent window; both now start a fresh one. The slow-connection meter was reset only when hls.js was built, so a direct answer showed the previous stream's numbers, and a replaced session's late fragment still counted; its rates now use the stats overlay's units.
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
formatCompact,
|
||||
formatCountOrDash,
|
||||
formatDecimal,
|
||||
formatMbps,
|
||||
formatMoney,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
@@ -285,11 +284,3 @@ describe("numbers in the UI language", () => {
|
||||
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMbps", () => {
|
||||
it("writes a rate in the browser's own unit, a decimal only under 10", () => {
|
||||
expect(formatMbps(19_600_000, "en")).toBe("20 Mb/s");
|
||||
expect(formatMbps(4_130_000, "en")).toBe("4.1 Mb/s");
|
||||
expect(formatMbps(4_130_000, "hu")).toBe("4,1 Mb/mp");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,20 +133,6 @@ export function formatUnit(
|
||||
);
|
||||
}
|
||||
|
||||
/** A rate in megabits per second, in the browser's own short form ("20 Mb/s" / "20 Mb/mp"): one
|
||||
* decimal under 10, whole above. */
|
||||
export function formatMbps(bitsPerSecond: number, lang: string): string {
|
||||
const mbps = bitsPerSecond / 1_000_000;
|
||||
const digits = mbps < 10 ? 1 : 0;
|
||||
return numberFormat(lang, {
|
||||
style: "unit",
|
||||
unit: "megabit-per-second",
|
||||
unitDisplay: "short",
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
}).format(mbps);
|
||||
}
|
||||
|
||||
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
|
||||
* BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on
|
||||
* the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total
|
||||
|
||||
@@ -718,6 +718,80 @@ describe("a link too slow for the stream", () => {
|
||||
expect(hlses[0]!.resumes).toBe(1);
|
||||
expect(asks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("asks for the session again when its playlist never answered", async () => {
|
||||
// Loading cannot resume what never parsed: hls.js reloads no manifest on startLoad, so a resume
|
||||
// here sent nothing, raised nothing, and left the spinner up for good.
|
||||
const { engine, asks, hlses, fire } = setup();
|
||||
engine.start("A", 300);
|
||||
await asks[0]!.resolve(hls("A", 300));
|
||||
hlses[0]!.timedOut("manifestLoadTimeOut");
|
||||
expect(fire()).toBe(true);
|
||||
expect(asks).toHaveLength(2);
|
||||
expect(asks[1]!.startAt).toBe(300);
|
||||
await asks[1]!.resolve(hls("A", 300));
|
||||
hlses[1]!.handlers.onManifestParsed();
|
||||
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
|
||||
});
|
||||
|
||||
it("says it cannot play once the playlist has not answered for the whole window", async () => {
|
||||
const { engine, asks, hlses, fire, advance } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.timedOut("manifestLoadTimeOut");
|
||||
advance(AWAY_WINDOW_MS + 1);
|
||||
expect(fire()).toBe(true);
|
||||
await asks[1]!.resolve(hls("A"));
|
||||
hlses[1]!.timedOut("manifestLoadTimeOut");
|
||||
expect(engine.getSnapshot().error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a fragment reports", () => {
|
||||
it("is passed on from the session asked for only, never from one it replaced", async () => {
|
||||
const seen: number[] = [];
|
||||
const { engine, asks, hlses } = setup({ onFragLoaded: (f) => void seen.push(f.bytes) });
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.resolve(hls("A"));
|
||||
hlses[0]!.arrived();
|
||||
engine.seek("A", 2000);
|
||||
await asks[1]!.resolve(hls("A", 2000));
|
||||
hlses[0]!.arrived(); // the replaced session's last fragment lands late
|
||||
expect(seen).toEqual([1_000_000]);
|
||||
hlses[1]!.arrived();
|
||||
expect(seen).toEqual([1_000_000, 1_000_000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an absence when no hls.js plays the stream", () => {
|
||||
it("ends with an answer the browser plays itself (native HLS)", async () => {
|
||||
// Without MSE an HLS answer plays natively and no fragment is ever reported, so the answer is
|
||||
// the only sign the server is back: a later absence gets its own window.
|
||||
const { engine, asks, advance, fire } = setup({ createHls: () => null });
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.reject({ status: 503 });
|
||||
expect(fire()).toBe(true);
|
||||
await asks[1]!.resolve(hls("A"));
|
||||
advance(AWAY_WINDOW_MS + 60_000);
|
||||
engine.seek("A", 900);
|
||||
await asks[2]!.reject({ status: 503 });
|
||||
expect(engine.getSnapshot().error).toBeNull();
|
||||
expect(fire()).toBe(true);
|
||||
});
|
||||
|
||||
it("starts afresh when the viewer tries again after the error was shown", async () => {
|
||||
const { engine, asks, advance, fire } = setup();
|
||||
engine.start("A", 0);
|
||||
await asks[0]!.reject({ status: 503 });
|
||||
advance(AWAY_WINDOW_MS + 1);
|
||||
expect(fire()).toBe(true);
|
||||
await asks[1]!.reject({ status: 503 });
|
||||
expect(engine.getSnapshot().error).toBe("generic");
|
||||
engine.start("A", 0); // the viewer presses play again
|
||||
await asks[2]!.reject({ status: 503 });
|
||||
expect(engine.getSnapshot().error).toBeNull();
|
||||
expect(fire()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("play intent while a session is asked for", () => {
|
||||
|
||||
@@ -318,6 +318,9 @@ export class HlsEngine {
|
||||
if (quiet) {
|
||||
this.set({ error: null });
|
||||
} else {
|
||||
// The viewer trying again after the error was shown: a new attempt, with a whole window of its
|
||||
// own — the spent one turned its first failure straight into the error again.
|
||||
if (this.snapshot.error) this.away = null;
|
||||
this.live = false;
|
||||
this.deps.onLoadStart?.();
|
||||
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
|
||||
@@ -377,9 +380,6 @@ export class HlsEngine {
|
||||
}
|
||||
const media = this.deps.media();
|
||||
if (!media) return;
|
||||
// A direct stream reports no fragments: its answer is the only sign the server is back. An HLS
|
||||
// absence ends when a fragment arrives (see `AWAY_WINDOW_MS`).
|
||||
if (sess.mode !== "hls") this.away = null;
|
||||
const { intent, startAt, id } = req;
|
||||
// The stream that kept playing is replaced from here: the new one loads like any other.
|
||||
if (req.quiet) this.set({ ready: false });
|
||||
@@ -412,6 +412,10 @@ export class HlsEngine {
|
||||
// Named for the stop on the way out whoever plays it: hls.js, or Safari's native HLS below.
|
||||
if (sess.mode === "hls" && sess.id) this.attachedSession = { id, sessionId: sess.id };
|
||||
const hls = sess.mode === "hls" ? this.deps.createHls(this.hlsHandlers(req, media)) : null;
|
||||
// What hls.js does not play reports no fragments — a direct file, or HLS the browser plays
|
||||
// natively (no MSE) — so its answer is the only sign the server is back. Under hls.js an
|
||||
// absence ends when a fragment arrives (see `AWAY_WINDOW_MS`).
|
||||
if (!hls) this.away = null;
|
||||
if (hls) {
|
||||
this.hls = hls;
|
||||
hls.load(`${sess.url}?t=${Math.floor(startAt)}`, media);
|
||||
@@ -478,7 +482,9 @@ export class HlsEngine {
|
||||
}
|
||||
},
|
||||
onFragLoaded: (frag) => {
|
||||
if (this.current(req)) this.away = null; // media gets through: whatever was away is back
|
||||
// A superseded session's late fragment is not this stream's measurement.
|
||||
if (!this.current(req)) return;
|
||||
this.away = null; // media gets through: whatever was away is back
|
||||
this.deps.onFragLoaded?.(frag);
|
||||
},
|
||||
onError: (e) => this.onHlsError(req, media, e),
|
||||
@@ -513,7 +519,9 @@ export class HlsEngine {
|
||||
return;
|
||||
}
|
||||
if (!e.fatal) return;
|
||||
if (e.network && e.timeout) {
|
||||
// A playlist that never answered is not resumed: hls.js reloads no manifest on startLoad, so a
|
||||
// resume sent nothing and the spinner stayed. It is asked for again, as a server away is.
|
||||
if (e.network && e.timeout && e.details !== "manifestLoadTimeOut") {
|
||||
this.slowLink();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -279,3 +279,39 @@ describe("an HLS segment that never arrives (a fatal 404)", () => {
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the slow-connection line", () => {
|
||||
it("measures only the stream being asked for, not the one before it", async () => {
|
||||
// A restart that a direct file answers builds no hls.js, so a meter reset only there kept the
|
||||
// slow HLS session's numbers on screen under the next stream.
|
||||
vi.useFakeTimers({
|
||||
shouldAdvanceTime: true,
|
||||
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
|
||||
});
|
||||
try {
|
||||
const session = mount();
|
||||
session
|
||||
.mockResolvedValueOnce({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never)
|
||||
.mockResolvedValueOnce({ mode: "direct", url: "/media/A", start_s: 0 } as never);
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(1));
|
||||
// 6 MB of a 6 s fragment (8 Mbit/s) that took 12 s to arrive (4 Mbit/s).
|
||||
act(() =>
|
||||
hlsInstances[0]!.emit(Hls.Events.FRAG_LOADED, {
|
||||
frag: {
|
||||
type: "main",
|
||||
duration: 6,
|
||||
stats: { total: 6_000_000, loading: { start: 0, first: 10, end: 12_000 } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
act(() => void vi.advanceTimersByTime(5_100));
|
||||
expect(screen.getByText("plex.player.slowLink")).toBeTruthy();
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404)); // restarted: answered direct
|
||||
await waitFor(() => expect(session).toHaveBeenCalledTimes(2));
|
||||
act(() => void vi.advanceTimersByTime(2_100));
|
||||
expect(screen.queryByText("plex.player.slowLink")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ import ClipPanel, { ClipCropFrame, type ClipCrop } from "../../features/player/C
|
||||
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { createFragMeter } from "../../lib/plexStats";
|
||||
import { createFragMeter, fmtBitrate } from "../../lib/plexStats";
|
||||
import { episodeLine } from "../../lib/plexDetail";
|
||||
import { HlsEngine } from "../../lib/hlsEngine";
|
||||
import { createHlsPort } from "../../lib/hlsPort";
|
||||
@@ -103,7 +103,7 @@ import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Slider } from "../../components/ui/NativeControls";
|
||||
import { formatMbps, formatUnit } from "../../lib/format";
|
||||
import { formatUnit } from "../../lib/format";
|
||||
import { trackLabel } from "../../lib/vocab";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
@@ -260,7 +260,12 @@ export default function PlexPlayer({
|
||||
// `req` rethrows fetch's own TypeError when nothing answered at all: that is a status of 0.
|
||||
statusOf: (e) =>
|
||||
e instanceof HttpError ? e.status : e instanceof TypeError ? 0 : undefined,
|
||||
onLoadStart: () => setBuffering(false),
|
||||
// Every stream asked for (another item, a seek, a restart) is measured from nothing: a
|
||||
// direct file answering it builds no hls.js, and must not show the previous one's numbers.
|
||||
onLoadStart: () => {
|
||||
setBuffering(false);
|
||||
fragMeterRef.current.reset();
|
||||
},
|
||||
onFragLoaded: (frag) => fragMeterRef.current.add(frag),
|
||||
// Fatal ones are what the viewer saw fail; the recoverable ones hls.js retries by itself only
|
||||
// matter to a detailed diagnosis.
|
||||
@@ -1346,8 +1351,8 @@ export default function PlexPlayer({
|
||||
<span>{t("plex.player.slowLink")}</span>
|
||||
<span className="text-xs text-white/70">
|
||||
{t("plex.player.slowLinkRates", {
|
||||
need: formatMbps(slowRates.need, i18n.language),
|
||||
got: formatMbps(slowRates.got, i18n.language),
|
||||
need: fmtBitrate(slowRates.need, i18n.language),
|
||||
got: fmtBitrate(slowRates.got, i18n.language),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user