Files
siftlode/frontend/src/lib/plexStats.ts
T
peter 36653886d5 build(frontend)!: knip drops --production; plain knip stays
The production pass (7ed2ac4e, E32) asked "is this export used by what
ships?". No real hit: its only findings were 37 exports a test imports,
answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not
defects. Since then it has only ever asked for another tag. The tags go
with it (36, no longer read by anything), and so do the production-only
`!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused
files, exports and dependencies) keeps running in the gate.
2026-10-05 00:19:38 +02:00

230 lines
10 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 (E6 S2). Kept free of
// React/DOM so the number-crunching is unit-testable: the component gathers raw values off videoRef /
// hlsRef 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 (E30 S3: "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 S1 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`;
}
// 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 S1 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 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",
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)
}
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();
},
};
}