Files
siftlode/frontend/src/lib/plexStats.ts
T
peter 470caacfb0 fix(plex): the slow line says both rates in one unit
Each rate took its own unit, so a link under a megabit read "needs 1.4
Mbps, 800 kbps arrives". `fmtBitrates` picks the unit the largest takes
for all of them. The away-window header now says what ends an absence
outside hls.js (the answer) and after the error (the viewer's retry).
2026-11-08 00:49:22 +01:00

262 lines
12 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.
// Pure formatters + row builder for the Plex player "stats for nerds" overlay. Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// the session engine 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).
// The numbers are in the UI language ("4,2 Mbps", "12,3 mp" in Hungarian); the unit symbols of a
// stream (Mbps, kbps) are the same in both.
import { formatDecimal, formatNumber, formatUnit } from "./format";
export interface PlexStatsInput {
lang: string; // the UI language, for the numbers
// 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 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
// 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;
}
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, lang: string): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
return `${formatNumber(Math.round(bps / 1000), lang)} kbps`;
}
// Rates read side by side ("needs 1.4 Mbps, 0.8 Mbps arrives"): one unit for all of them, the one
// the largest would take alone, so they compare at a glance — never "1.4 Mbps" beside "800 kbps".
export function fmtBitrates(rates: number[], lang: string): string[] {
const largest = Math.max(0, ...rates);
if (largest < 1_000_000) return rates.map((bps) => fmtBitrate(bps, lang));
return rates.map((bps) =>
bps > 0 ? `${formatDecimal(bps / 1_000_000, lang)} Mbps` : fmtBitrate(bps, lang),
);
}
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return formatUnit(s, "second", lang, 1);
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
export function fmtFrames(dropped: number, total: number, lang: string): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
const n = (x: number) => formatNumber(x, lang);
return `${n(dropped)} / ${n(total)} (${formatDecimal(pct, lang, 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 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.measuredBps, s.lang),
},
{ key: "level", labelKey: "plex.player.stats.level", value: fmtLevel(s.hlsLevel) },
);
}
rows.push(
// Which audio/subtitle track is actually playing. The player can switch both, 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",
value: fmtFrames(s.droppedFrames, s.totalFrames, s.lang),
},
{
key: "buffer",
labelKey: "plex.player.stats.buffer",
value: fmtBufferSecs(s.bufferAheadS, s.lang),
},
{ 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;
}
// --- 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)
loading?: { start: number; end: number }; // when its download ran (ms), when hls.js knew
}
export interface FragMeter {
add(sample: FragSample): void;
/** Bits per second of media, or undefined while nothing has been measured yet. */
bitrate(): number | undefined;
/** Bits per second the link delivered them at, or undefined while no download time is known: every
* timed fragment's bytes over the time ANY of them was downloading. Not per type and summed like
* `bitrate` — the renditions share one link, and their downloads overlap (measured: summing read
* 2.3 Mbit/s through a 1.6 Mbit/s link). */
arrival(): 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;
},
arrival() {
const timed = [...byType.values()]
.flat()
.filter((f) => f.loading && f.loading.end > f.loading.start)
.sort((a, b) => a.loading!.start - b.loading!.start);
let busyMs = 0;
let until = -Infinity;
for (const { loading } of timed) {
const { start, end } = loading!;
if (end <= until) continue;
busyMs += end - Math.max(start, until);
until = end;
}
const bytes = timed.reduce((n, f) => n + f.bytes, 0);
return busyMs > 0 ? (bytes * 8) / (busyMs / 1000) : undefined;
},
reset() {
byType.clear();
},
};
}