e2e/run.sh builds the collector, installs the automation package and an open-source web player from e2e/package-lock.json (npm ci, no install scripts), runs the player integration's unit tests, then e2e/audit.e2e.mjs: an HLS stream made with the host's media tool (H.264 and AAC in 2 s CMAF segments, a program time, a marked ad over two segments) served from another origin with CMSD-Static exposed, played in a real browser twice, once through the loader adapter and once through the network tap, each session sent to a collector on loopback. It checks the outcome, the acknowledged objects against the origin's own SHA-256 of what it served, the ad's claim, the CMSD notes, the unmetered energy, the CMCD batches, the report, its recomputation by `wai-audit check`, a replay stored once and a tampered record refused. Three defects it found, fixed here: - the player hands fragment buffers on (to a worker), emptying them after the loader's callback: the session now copies the bytes at once, and keeps each object in the order it was given; - the tap read XMLHttpRequest responses after the player had moved them: it now listens from the request's construction, so it reads first; - a stitched ad is entered between two observations, so its claim began past media time 0 and reached quartile 1: play that enters an ad continuously now starts its first span at 0 (stream-audit §6.3), as leaving it already ran the last span to the ad's end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
792 lines
34 KiB
JavaScript
792 lines
34 KiB
JavaScript
// The player integration of WAI's stream audit (wai/extensions/stream-audit.md): what a player
|
|
// signs for one playback session of a stream another service delivers over HTTP, and the bundle
|
|
// it sends to the owner's collector (§9).
|
|
//
|
|
// AuditSession one session: its key, its objects, its spans, its ads, its log, its record
|
|
// acknowledge settlement §3 acknowledgements over plain SHA-256 (§4, settlement §14)
|
|
// ContentSpans observations to the record's spans (§5.2)
|
|
// AdSpans observations to a claim's spans over an ad's media time (ad-presentation §10)
|
|
// parsePlaylist an HLS media playlist: its segments, program times and marked ads (§6.1)
|
|
// attach a media element's observations, every 200 ms and on its events
|
|
//
|
|
// Everything is the platform's: WebCrypto's SHA-256 and Ed25519, IntersectionObserver, the media
|
|
// element; BLAKE3 for jwp-receipts §2.2's tree root is ./blake3.js. No dependency.
|
|
|
|
import { blake3 } from "./blake3.js";
|
|
|
|
const enc = new TextEncoder();
|
|
const subtle = globalThis.crypto.subtle;
|
|
|
|
export const SESSION_MAGIC = enc.encode("WAS1");
|
|
export const SESSION_DOMAIN = enc.encode("wai:audit-session\x01");
|
|
export const CHANNEL_DOMAIN = enc.encode("wai:audit-channel\x01");
|
|
export const MARKER_DOMAIN = enc.encode("wai:audit-marker\x01");
|
|
export const ORIGIN_DOMAIN = enc.encode("wai:settle-origin\x01");
|
|
export const ACK_MAGIC = enc.encode("WDA1");
|
|
export const ACK_DOMAIN = enc.encode("wai:settle-ack\x01");
|
|
export const ITEMS_DOMAIN = enc.encode("wai:settle-items\x01");
|
|
export const LEAF_DOMAIN = enc.encode("moq-jwp:merkle-leaf\x01");
|
|
export const NODE_DOMAIN = enc.encode("moq-jwp:merkle-node\x01");
|
|
export const KEY_SESSION_DOMAIN = enc.encode("wai:ad-presentation-session\x01");
|
|
export const CLAIM_MAGIC = enc.encode("WPC1");
|
|
export const CLAIM_DOMAIN = enc.encode("wai:ad-presentation-claim\x01");
|
|
export const CREATIVE_DOMAIN = enc.encode("wai:ad-creative\x01");
|
|
export const LIVE = 1;
|
|
export const ON_DEMAND = 2;
|
|
export const ENDED = { ended: 1, user: 2, hidden: 3, idle: 4, error: 5 };
|
|
export const ROLE = { ORIGIN: 1, DELIVERY: 2, SINK_DECODE: 3, DISPLAY: 4 };
|
|
export const AUDIBLE = 1;
|
|
export const AD = 2;
|
|
export const RESUMED = 2;
|
|
export const GEOMETRY_PLAYER = 3;
|
|
export const NOT_EXPOSED = 0xffffffff;
|
|
export const NO_STARTUP = 0xffffffff;
|
|
export const DEFAULT_WINDOW_US = 10_000_000;
|
|
|
|
/** energy-measurement §2.7's class bytes: the unmetered marker, `ModelBased`, `Estimator`. */
|
|
export const CLASS = { UNMETERED: Uint8Array.of(0), MODEL_BASED: Uint8Array.of(2), ESTIMATOR: Uint8Array.of(3) };
|
|
|
|
/** An `OnChipCounter` class: its declared uncertainty and window, and its filtering (0 unknown, 1 off, 2 on). */
|
|
export function onChipCounter(relativePpm, windowUs, filtering) {
|
|
const b = new Uint8Array(14);
|
|
const dv = new DataView(b.buffer);
|
|
b[0] = 4;
|
|
dv.setUint32(1, relativePpm, false);
|
|
dv.setBigUint64(5, BigInt(windowUs), false);
|
|
b[13] = filtering;
|
|
return b;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- bytes
|
|
|
|
export const concat = (...parts) => {
|
|
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
let at = 0;
|
|
for (const p of parts) {
|
|
out.set(p, at);
|
|
at += p.length;
|
|
}
|
|
return out;
|
|
};
|
|
const u8 = (v) => Uint8Array.of(v & 0xff);
|
|
const u16 = (v) => Uint8Array.of(v & 0xff, (v >>> 8) & 0xff);
|
|
const u32 = (v) => {
|
|
const b = new Uint8Array(4);
|
|
new DataView(b.buffer).setUint32(0, v >>> 0, true);
|
|
return b;
|
|
};
|
|
const u64 = (v) => {
|
|
const b = new Uint8Array(8);
|
|
new DataView(b.buffer).setBigUint64(0, BigInt(v), true);
|
|
return b;
|
|
};
|
|
export const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
export const unhex = (s) => Uint8Array.from(s.match(/../g) ?? [], (h) => parseInt(h, 16));
|
|
const cmp = (a, b) => {
|
|
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
return a.length - b.length;
|
|
};
|
|
const ZERO = new Uint8Array(32);
|
|
|
|
export async function sha256(...parts) {
|
|
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §3, settlement §14
|
|
|
|
/** The channel id of a name (§3.1). */
|
|
export async function channelId(name) {
|
|
if (!/^[\x21-\x7e]{1,255}$/.test(name)) throw new RangeError("a channel name is 1 to 255 bytes of printable ASCII");
|
|
return sha256(CHANNEL_DOMAIN, enc.encode(name));
|
|
}
|
|
|
|
/** The session of a session key in a channel (§3.2). */
|
|
export async function sessionOf(channel, key) {
|
|
return (await sha256(KEY_SESSION_DOMAIN, channel, key)).subarray(0, 16);
|
|
}
|
|
|
|
/** The upstream an origin names (settlement §14.2). */
|
|
export async function originUpstream(origin) {
|
|
return sha256(ORIGIN_DOMAIN, enc.encode(origin));
|
|
}
|
|
|
|
/** The marker digest of a marker's text (§6.1). */
|
|
export async function markerDigest(marker) {
|
|
return sha256(MARKER_DOMAIN, enc.encode(marker));
|
|
}
|
|
|
|
/** jwp-receipts §2.2's tree root in its first form, over 32-byte content hashes. */
|
|
export function treeRoot(hashes) {
|
|
if (hashes.length === 0) return ZERO;
|
|
let level = hashes.map((h) => blake3(concat(LEAF_DOMAIN, h)));
|
|
while (level.length > 1) {
|
|
if (level.length % 2) level.push(level[level.length - 1]);
|
|
const next = [];
|
|
for (let i = 0; i < level.length; i += 2) next.push(blake3(concat(NODE_DOMAIN, level[i], level[i + 1])));
|
|
level = next;
|
|
}
|
|
return level[0];
|
|
}
|
|
|
|
/** An item list (settlement §3.4): whole objects, `[{ hash, length }]`. */
|
|
export function itemList(items) {
|
|
return concat(...items.flatMap((i) => [i.hash, u64(0), u64(i.length)]));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- keys
|
|
|
|
/** A session key from 32 bytes of seed (the corpus's keys; a player makes a fresh one). */
|
|
export async function keyFromSeed(seed) {
|
|
const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed);
|
|
const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
|
|
const jwk = await subtle.exportKey("jwk", privateKey);
|
|
const b64 = jwk.x.replace(/-/g, "+").replace(/_/g, "/");
|
|
const publicKey = Uint8Array.from(atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)), (c) => c.charCodeAt(0));
|
|
return { privateKey, publicKey };
|
|
}
|
|
|
|
/** A fresh session key (§3.2): the platform's random generator, used for this session alone. */
|
|
export async function newKey() {
|
|
const k = await subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
|
|
return { privateKey: k.privateKey, publicKey: new Uint8Array(await subtle.exportKey("raw", k.publicKey)) };
|
|
}
|
|
|
|
async function sign(privateKey, domain, body) {
|
|
return new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(domain, body)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §4 acknowledgements
|
|
|
|
/**
|
|
* §4.1, §4.2: the acknowledgements of the objects received, `[{ at, origin, media, length, hash }]`
|
|
* in the order received, in windows of `windowUs` from `start`, the last ending at `end`: one per
|
|
* origin and window with an object, chained per origin, ordered by origin then window; a repeat
|
|
* within a window counted and not listed. Gives `{ acks: [{ record, items, digest }], repeatObjects,
|
|
* repeatBytes }`.
|
|
*/
|
|
export async function acknowledge(key, presentation, start, end, windowUs, received) {
|
|
const mediaClass = presentation === LIVE ? 1 : 2;
|
|
const windows = new Map(); // "origin\u0000window" -> { origin, k, classes: Map(class -> items) }
|
|
const seen = new Set();
|
|
let repeatObjects = 0;
|
|
let repeatBytes = 0;
|
|
for (const r of received) {
|
|
const k = Math.floor((r.at - start) / windowUs);
|
|
const id = `${r.origin}\u0000${k}\u0000${hex(r.hash)}`;
|
|
if (seen.has(id)) {
|
|
repeatObjects += 1;
|
|
repeatBytes += r.length;
|
|
continue;
|
|
}
|
|
seen.add(id);
|
|
const w = `${r.origin}\u0000${k}`;
|
|
if (!windows.has(w)) windows.set(w, { origin: r.origin, k, classes: new Map() });
|
|
const cls = r.media ? mediaClass : 4;
|
|
const c = windows.get(w).classes;
|
|
if (!c.has(cls)) c.set(cls, []);
|
|
c.get(cls).push({ hash: r.hash, length: r.length });
|
|
}
|
|
const order = [...windows.values()].sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : a.k - b.k));
|
|
const prev = new Map();
|
|
const acks = [];
|
|
for (const w of order) {
|
|
const ws = start + w.k * windowUs;
|
|
const we = Math.min(ws + windowUs, end);
|
|
const classes = [...w.classes.entries()].sort((a, b) => a[0] - b[0]);
|
|
const lists = classes.map(([, items]) => itemList(items));
|
|
const entries = await Promise.all(
|
|
classes.map(async ([cls, items], i) =>
|
|
concat(u8(cls), ZERO, ZERO, u32(items.length), u64(items.reduce((n, x) => n + x.length, 0)), treeRoot(items.map((x) => x.hash)), await sha256(ITEMS_DOMAIN, lists[i])),
|
|
),
|
|
);
|
|
const body = concat(ACK_MAGIC, u8(1), u8(1), u8(2), await originUpstream(w.origin), key.publicKey, u64(ws), u64(we), prev.get(w.origin) ?? ZERO, u16(entries.length), ...entries);
|
|
const digest = await sha256(body);
|
|
prev.set(w.origin, digest);
|
|
acks.push({ record: concat(body, await sign(key.privateKey, ACK_DOMAIN, body)), items: lists, digest });
|
|
}
|
|
return { acks, repeatObjects, repeatBytes };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §5.2 spans
|
|
|
|
/**
|
|
* The record's spans (§5.2): observations `{ at (µs), playing, visibleBp, audible, ad }`; a span
|
|
* begins at each change of playback, of the visible fraction's step (`quantumBp`), of audibility
|
|
* or of being within an ad; its fraction is the least observed over it.
|
|
*/
|
|
export class ContentSpans {
|
|
constructor(start, { quantumBp = 100 } = {}) {
|
|
this.start = start;
|
|
this.quantum = quantumBp;
|
|
this.done = [];
|
|
this.cur = null;
|
|
}
|
|
|
|
ms(at) {
|
|
return Math.max(0, Math.floor((at - this.start) / 1000));
|
|
}
|
|
|
|
close(at) {
|
|
if (this.cur) {
|
|
const end = this.ms(at);
|
|
if (end > this.cur.startMs) {
|
|
const flags = (this.cur.audible ? AUDIBLE : 0) | (this.cur.ad ? AD : 0);
|
|
this.done.push({ startMs: this.cur.startMs, lengthMs: end - this.cur.startMs, visibleBp: this.cur.visibleBp, flags });
|
|
}
|
|
}
|
|
this.cur = null;
|
|
}
|
|
|
|
observe({ at, playing, visibleBp, audible, ad = false }) {
|
|
if (!playing) return this.close(at);
|
|
const vis = Math.max(0, Math.min(10_000, Math.round(visibleBp)));
|
|
const cls = Math.floor(vis / this.quantum);
|
|
if (this.cur && (cls !== this.cur.cls || !!audible !== this.cur.audible || !!ad !== this.cur.ad)) this.close(at);
|
|
if (this.cur) {
|
|
this.cur.visibleBp = Math.min(this.cur.visibleBp, vis);
|
|
return;
|
|
}
|
|
this.cur = { startMs: this.ms(at), visibleBp: vis, cls, audible: !!audible, ad: !!ad };
|
|
}
|
|
|
|
spans(at) {
|
|
this.close(at);
|
|
return this.done.slice();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A claim's spans over an ad's media time, by ad-presentation §10's rules: `{ positionMs, playing,
|
|
* visibleBp, audible, seeked }`; media time already stated is never stated again, and a span after
|
|
* a pause, a stall or a seek is a resumption.
|
|
*/
|
|
export class AdSpans {
|
|
constructor({ quantumBp = 100 } = {}) {
|
|
this.quantum = quantumBp;
|
|
this.done = [];
|
|
this.cur = null;
|
|
this.maxEnd = 0;
|
|
this.resumeNext = false;
|
|
}
|
|
|
|
close() {
|
|
if (this.cur && this.cur.endMs > this.cur.startMs) {
|
|
const flags = (this.cur.audible ? AUDIBLE : 0) | (this.cur.resumed ? RESUMED : 0);
|
|
this.done.push({ startMs: this.cur.startMs, lengthMs: this.cur.endMs - this.cur.startMs, visibleBp: this.cur.visibleBp, flags });
|
|
this.maxEnd = Math.max(this.maxEnd, this.cur.endMs);
|
|
}
|
|
this.cur = null;
|
|
}
|
|
|
|
observe({ positionMs, playing, visibleBp, audible, seeked = false }) {
|
|
const pos = Math.max(0, Math.floor(positionMs));
|
|
if (!playing || seeked) {
|
|
this.close();
|
|
if (this.done.length > 0) this.resumeNext = true;
|
|
if (!playing) return;
|
|
}
|
|
const vis = Math.max(0, Math.min(10_000, Math.round(visibleBp)));
|
|
const cls = Math.floor(vis / this.quantum);
|
|
if (this.cur) {
|
|
if (pos < this.cur.endMs) return;
|
|
if (cls !== this.cur.cls || !!audible !== this.cur.audible) {
|
|
this.cur.endMs = pos;
|
|
this.close();
|
|
} else {
|
|
this.cur.endMs = pos;
|
|
this.cur.visibleBp = Math.min(this.cur.visibleBp, vis);
|
|
return;
|
|
}
|
|
}
|
|
if (pos < this.maxEnd) return;
|
|
const continues = !this.resumeNext && this.done.length > 0 && pos === this.maxEnd;
|
|
const resumed = this.done.length > 0 && !continues;
|
|
this.cur = { startMs: pos, endMs: pos, visibleBp: vis, cls, audible: !!audible, resumed };
|
|
this.resumeNext = false;
|
|
}
|
|
|
|
spans() {
|
|
this.close();
|
|
return this.done.slice();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §6 claims
|
|
|
|
/** The creative digest of `[{ hash, durationMs }]` (ad-presentation §4). */
|
|
export async function creativeDigest(objects) {
|
|
return sha256(CREATIVE_DOMAIN, u32(objects.length), ...objects.flatMap((o) => [o.hash, u32(o.durationMs)]));
|
|
}
|
|
|
|
/** The content hashes of the objects the spans touch, in order (ad-presentation §9.2 step 6). */
|
|
export function touched(objects, spans) {
|
|
const out = [];
|
|
let s = 0;
|
|
for (const o of objects) {
|
|
const e = s + o.durationMs;
|
|
if (spans.some((x) => x.startMs < e && x.startMs + x.lengthMs > s)) out.push(o.hash);
|
|
s = e;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A presentation claim (ad-presentation §5) for an ad of the channel (§6.3), signed. */
|
|
export async function signClaim(key, c) {
|
|
const spans = c.spans.flatMap((s) => [u32(s.startMs), u32(s.lengthMs), u16(s.visibleBp), u8(s.flags)]);
|
|
const body = concat(
|
|
CLAIM_MAGIC, u8(1), c.channel, await markerDigest(c.marker), c.session, u8(c.slot ?? 0), await creativeDigest(c.creative), key.publicKey,
|
|
u8(c.geometry ?? GEOMETRY_PLAYER), u32(c.frames ?? NOT_EXPOSED), u16(c.objects.length), ...c.objects, u16(c.spans.length), ...spans, u16(0),
|
|
);
|
|
return concat(body, await sign(key.privateKey, CLAIM_DOMAIN, body));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §7 the log and CMCD
|
|
|
|
const OBJECT_NULLS = ["ot", "url", "content_hash", "capability", "rendition", "layer", "layers_total", "layer_cap", "counted", "transferred", "d_ms", "dl_ms", "ttfb_ms", "ttlb_ms", "rc", "path", "receipt"];
|
|
|
|
/** A delivery-telemetry observation (§2.1), every member in its order. */
|
|
export function observation(sid, sn, at, event, st, { contentHash = null, rc = null, present = null } = {}) {
|
|
let object = null;
|
|
if (event === "response") {
|
|
object = Object.fromEntries(OBJECT_NULLS.map((k) => [k, null]));
|
|
object.content_hash = contentHash;
|
|
object.rc = rc;
|
|
}
|
|
return {
|
|
v: 1, sid, sn, ts_ms: Math.floor(at / 1000), event, cid: null, st, sf: "h", object, present,
|
|
switch: null, layer_change: null, capable: null, cohort: null, metrics: null, claim: null,
|
|
};
|
|
}
|
|
|
|
/** delivery-telemetry §7.2's start time and stalls of a log. */
|
|
export function sessionMetrics(log) {
|
|
let start = null;
|
|
let present = null;
|
|
let stall = null;
|
|
let stalls = 0;
|
|
let stallMs = 0;
|
|
for (const o of log) {
|
|
const t = o.ts_ms;
|
|
if (o.event === "start" && start === null) start = t;
|
|
else if (o.event === "present" && o.present.first) present = t;
|
|
else if (o.event === "stall-start" && present !== null && stall === null) stall = t;
|
|
else if (o.event === "stall-end") {
|
|
if (stall !== null) {
|
|
stalls += 1;
|
|
stallMs += t - stall;
|
|
}
|
|
stall = null;
|
|
} else if ((o.event === "end" || o.event === "abandon") && stall !== null) {
|
|
stalls += 1;
|
|
stallMs += t - stall;
|
|
stall = null;
|
|
}
|
|
}
|
|
const vst = present !== null && start !== null ? present - Math.min(start, present) : null;
|
|
return { vst, stalls, stallMs };
|
|
}
|
|
|
|
const PREFIX = "science.transaction.wai-";
|
|
const sfString = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
|
|
/** The Event-mode CMCD report of an observation (delivery-telemetry §3, §4), or null. */
|
|
export function cmcdReport(o) {
|
|
if (o.event === "present" && !o.present.first) return null;
|
|
const m = new Map();
|
|
m.set("v", "2");
|
|
m.set("sid", sfString(o.sid));
|
|
m.set("sn", String(o.sn));
|
|
if (o.cid !== null) m.set("cid", sfString(o.cid));
|
|
m.set("st", o.st);
|
|
m.set("sf", o.sf);
|
|
if (o.object) {
|
|
if (o.object.content_hash !== null) m.set(PREFIX + "ch", sfString(o.object.content_hash));
|
|
if (o.object.rc !== null) m.set("rc", String(o.object.rc));
|
|
}
|
|
m.set("ts", String(o.ts_ms));
|
|
const [e, sta] = { response: ["rr", null], start: ["ps", "s"], present: ["ps", "p"], "stall-end": ["ps", "p"], "stall-start": ["ps", "r"], abandon: ["ps", "q"], end: ["ps", "e"] }[o.event];
|
|
m.set("e", e);
|
|
if (sta) m.set("sta", sta);
|
|
if (o.present) {
|
|
m.set(PREFIX + "u", sfString(String(o.present.unit)));
|
|
m.set(PREFIX + "pl", sfString(String(o.present.layers)));
|
|
}
|
|
return [...m.keys()].sort().map((k) => `${k}=${m.get(k)}`).join(",");
|
|
}
|
|
|
|
/** The `application/cmcd` batch of observations (live-metering §6.3). */
|
|
export function cmcdBatch(log) {
|
|
return log.map(cmcdReport).filter((x) => x !== null).join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------- §6.1 playlists
|
|
|
|
/** The attributes of a tag's attribute list (`KEY=value,KEY="quoted, value"`). */
|
|
export function attributes(list) {
|
|
const out = {};
|
|
const re = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g;
|
|
let m;
|
|
while ((m = re.exec(list))) out[m[1]] = m[2].startsWith('"') ? m[2].slice(1, -1) : m[2];
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* An HLS media playlist: its segments `{ uri, durationMs, programMs }` (absolute URIs; program
|
|
* times from `EXT-X-PROGRAM-DATE-TIME`), whether it is on demand (`EXT-X-ENDLIST`), the key URIs
|
|
* it names, and its marked ads `{ marker, startMs, durationMs }` (§6.1): `EXT-X-DATERANGE` tags
|
|
* with `SCTE35-OUT` or `SCTE35-CMD`, or a `CLASS` in `adClasses`.
|
|
*/
|
|
export function parsePlaylist(text, base, { adClasses = [] } = {}) {
|
|
const segments = [];
|
|
const ads = [];
|
|
const keys = [];
|
|
let duration = null;
|
|
let program = null;
|
|
let map = null;
|
|
let onDemand = false;
|
|
for (const raw of text.split(/\r?\n/)) {
|
|
const line = raw.trimEnd();
|
|
if (line.startsWith("#EXTINF:")) duration = Math.max(1, Math.round(parseFloat(line.slice(8)) * 1000));
|
|
else if (line.startsWith("#EXT-X-PROGRAM-DATE-TIME:")) program = Date.parse(line.slice(25));
|
|
else if (line.startsWith("#EXT-X-ENDLIST")) onDemand = true;
|
|
else if (line.startsWith("#EXT-X-MAP:")) map = new URL(attributes(line.slice(11)).URI, base).href;
|
|
else if (line.startsWith("#EXT-X-KEY:") || line.startsWith("#EXT-X-SESSION-KEY:")) {
|
|
const a = attributes(line.slice(line.indexOf(":") + 1));
|
|
if (a.URI) keys.push(new URL(a.URI, base).href);
|
|
} else if (line.startsWith("#EXT-X-DATERANGE:")) {
|
|
const a = attributes(line.slice(17));
|
|
const isAd = "SCTE35-OUT" in a || "SCTE35-CMD" in a || adClasses.includes(a.CLASS);
|
|
const d = parseFloat(a.DURATION ?? a["PLANNED-DURATION"] ?? "NaN");
|
|
if (isAd && Number.isFinite(d)) ads.push({ marker: line, id: a.ID, startMs: Date.parse(a["START-DATE"]), durationMs: Math.round(d * 1000) });
|
|
} else if (line && !line.startsWith("#")) {
|
|
segments.push({ uri: new URL(line, base).href, durationMs: duration ?? 1, programMs: program });
|
|
if (program !== null && duration !== null) program += duration;
|
|
duration = null;
|
|
}
|
|
}
|
|
return { segments, ads, keys, map, onDemand };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the session
|
|
|
|
/**
|
|
* One playback session of a channel (§3.2): `await AuditSession.start({ channel, owner, collector })`,
|
|
* then `object()` for every response the player fetched, `playlist()` for each media playlist's
|
|
* text, `observe()` as it plays, `event()` for the log's start, first presentation, stalls and end,
|
|
* `energy()` for a role's figure, and `close(ended)` for the bundle, which `send()` posts.
|
|
*/
|
|
export class AuditSession {
|
|
static async start({ channel, owner, collector = null, presentation = null, windowUs = DEFAULT_WINDOW_US, key = null, now = () => Date.now() * 1000, adClasses = [], cmcdEveryMs = 10_000 }) {
|
|
const s = new AuditSession();
|
|
s.now = now;
|
|
s.start = now();
|
|
s.channelName = channel;
|
|
s.channel = await channelId(channel);
|
|
s.owner = typeof owner === "string" ? unhex(owner) : owner;
|
|
s.collector = collector;
|
|
s.presentation = presentation;
|
|
s.windowUs = Math.max(1_000_000, windowUs);
|
|
s.key = key ?? (await newKey());
|
|
s.session = await sessionOf(s.channel, s.key.publicKey);
|
|
s.sid = hex(s.session);
|
|
s.adClasses = adClasses;
|
|
s.received = [];
|
|
s.pending = [];
|
|
s.byUrl = new Map();
|
|
s.notes = [];
|
|
s.log = [];
|
|
s.sentCmcd = 0;
|
|
s.spans = new ContentSpans(s.start);
|
|
s.segments = new Map();
|
|
s.ads = new Map(); // marker -> { marker, startMs, durationMs, recorder }
|
|
s.keyUris = new Set();
|
|
s.manualClaims = [];
|
|
s.energyEntries = new Map();
|
|
s.attachments = [];
|
|
s.closed = null;
|
|
if (collector && cmcdEveryMs > 0 && typeof setInterval === "function") s.timer = setInterval(() => s.flushCmcd(), cmcdEveryMs);
|
|
return s;
|
|
}
|
|
|
|
st() {
|
|
return (this.presentation ?? LIVE) === LIVE ? "l" : "v";
|
|
}
|
|
|
|
/** A log event: `start`, `present` (the first presentation), `stall-start`, `stall-end`, `end`, `abandon`. */
|
|
event(name, at = this.now()) {
|
|
if (name === "present" && this.log.some((o) => o.event === "present")) return;
|
|
const present = name === "present" ? { unit: 0, layers: 1, first: true, late_ms: null, decode_us: null } : null;
|
|
this.log.push(observation(this.sid, this.log.length, at, name, this.st(), { present }));
|
|
}
|
|
|
|
/** Whether a URL is one the session must not acknowledge: a key a playlist names (§4.1). */
|
|
isKey(url) {
|
|
return this.keyUris.has(url);
|
|
}
|
|
|
|
/**
|
|
* A response the player received: its URL (after redirects), its bytes with content codings
|
|
* removed, whether it is media, its status, and the CMSD fields it exposed, if any.
|
|
*/
|
|
object({ url, bytes, media, at = this.now(), status = 200, cmsdStatic = null, cmsdDynamic = null }) {
|
|
if (this.closed || this.isKey(url)) return Promise.resolve(null);
|
|
// A copy, now: a player may hand the buffer on (and so empty it) as soon as this returns. The
|
|
// object takes its place in the session in the order it is given, whenever its hash is done.
|
|
const data = Uint8Array.from(bytes);
|
|
const entry = { at, origin: new URL(url).origin, media, length: data.length, hash: null };
|
|
this.received.push(entry);
|
|
const o = observation(this.sid, this.log.length, at, "response", this.st(), { contentHash: null, rc: status });
|
|
this.log.push(o);
|
|
const note = cmsdStatic !== null || cmsdDynamic !== null ? { content_hash: null, cmsd_static: cmsdStatic, cmsd_dynamic: cmsdDynamic } : null;
|
|
if (note) this.notes.push(note);
|
|
const p = sha256(data).then((hash) => {
|
|
entry.hash = hash;
|
|
o.object.content_hash = hex(hash);
|
|
if (note) note.content_hash = hex(hash);
|
|
this.byUrl.set(url, hash);
|
|
return hash;
|
|
});
|
|
this.pending.push(p);
|
|
return p;
|
|
}
|
|
|
|
/** A media playlist's text: its segments and marked ads join the session's (§6.1). */
|
|
playlist(text, url) {
|
|
const p = parsePlaylist(text, url, { adClasses: this.adClasses });
|
|
if (this.presentation === null) this.presentation = p.onDemand ? ON_DEMAND : LIVE;
|
|
for (const k of p.keys) this.keyUris.add(k);
|
|
for (const s of p.segments) this.segments.set(s.uri, s);
|
|
for (const a of p.ads) if (!this.ads.has(a.marker)) this.ads.set(a.marker, { ...a, recorder: new AdSpans() });
|
|
return p;
|
|
}
|
|
|
|
/**
|
|
* The program time of media time `seconds`, for an on-demand presentation whose first segment
|
|
* starts at media time 0: the first segment's program time plus it; null with no program time.
|
|
* A player that knows the program time of its position (a live one) gives it instead.
|
|
*/
|
|
programAt(seconds) {
|
|
let first = null;
|
|
for (const s of this.segments.values()) if (s.programMs !== null && (first === null || s.programMs < first)) first = s.programMs;
|
|
return first === null ? null : first + Math.round(seconds * 1000);
|
|
}
|
|
|
|
/** The marked ad a program time is within, or null. */
|
|
adAt(programMs) {
|
|
if (programMs === null || programMs === undefined) return null;
|
|
for (const a of this.ads.values()) if (programMs >= a.startMs && programMs < a.startMs + a.durationMs) return a;
|
|
return null;
|
|
}
|
|
|
|
/** The presented playlist's segments a marked ad's span meets, in order (§6.2). */
|
|
adSegments(ad) {
|
|
const segs = [...this.segments.values()].filter((s) => s.programMs !== null && s.programMs < ad.startMs + ad.durationMs && s.programMs + s.durationMs > ad.startMs);
|
|
return segs.sort((a, b) => a.programMs - b.programMs);
|
|
}
|
|
|
|
/** The creative of a marked ad: its segments, each by the hash of what was fetched; null while one is not (§6.2). */
|
|
creativeOf(ad) {
|
|
const segs = this.adSegments(ad);
|
|
if (segs.length === 0 || segs.some((s) => !this.byUrl.has(s.uri))) return null;
|
|
return { objects: segs.map((s) => ({ hash: this.byUrl.get(s.uri), durationMs: s.durationMs })), mediaStart: segs[0].programMs };
|
|
}
|
|
|
|
/** An observation of playback: `{ at, playing, visibleBp, audible, programMs, seeked }`. */
|
|
observe({ at = this.now(), playing, visibleBp, audible, programMs = null, seeked = false }) {
|
|
const ad = this.adAt(programMs);
|
|
this.spans.observe({ at, playing, visibleBp, audible, ad: ad !== null });
|
|
for (const a of this.ads.values()) {
|
|
const segs = this.adSegments(a);
|
|
const base = segs.length ? segs[0].programMs : a.startMs;
|
|
if (a === ad) {
|
|
// Played into the ad from before it: playback was continuous across its start, so its
|
|
// first span starts at media time 0 (§6.3), as leaving it runs a span to where it left.
|
|
const last = this.last;
|
|
if (!a.recorder.cur && a.recorder.done.length === 0 && playing && !seeked && last?.playing && last.programMs !== null && last.programMs < base) {
|
|
a.recorder.observe({ positionMs: 0, playing: true, visibleBp, audible });
|
|
}
|
|
a.recorder.observe({ positionMs: programMs - base, playing, visibleBp, audible, seeked });
|
|
} else if (a.recorder.cur) {
|
|
// Playback left the ad: its span runs to where it left, at most the creative's end.
|
|
if (playing && programMs !== null && !seeked) {
|
|
const d = segs.length ? segs.reduce((n, o) => n + o.durationMs, 0) : a.durationMs;
|
|
a.recorder.observe({ positionMs: Math.min(programMs - base, d), playing: true, visibleBp: a.recorder.cur.visibleBp, audible: a.recorder.cur.audible });
|
|
}
|
|
a.recorder.observe({ positionMs: 0, playing: false, visibleBp: 0, audible: false });
|
|
}
|
|
}
|
|
this.last = { playing, programMs };
|
|
}
|
|
|
|
/** A claim with its parts given directly: `{ marker, creative: [{ hash, durationMs }], spans, geometry }`. */
|
|
claim(c) {
|
|
this.manualClaims.push(c);
|
|
}
|
|
|
|
/** A role's figure over the session: its microjoules and its class bytes (§5.3). */
|
|
energy(role, joulesMicro, classBytes) {
|
|
this.energyEntries.set(role, { role, joules: joulesMicro, cls: classBytes });
|
|
}
|
|
|
|
/** A record to attach (§8.3): its registered name and its bytes. */
|
|
attach(name, bytes) {
|
|
this.attachments.push({ name, bytes });
|
|
}
|
|
|
|
/** Send the Event-mode reports not yet sent (§7.2). */
|
|
async flushCmcd() {
|
|
if (!this.collector) return;
|
|
await Promise.all(this.pending);
|
|
const fresh = this.log.slice(this.sentCmcd);
|
|
const body = cmcdBatch(fresh);
|
|
this.sentCmcd = this.log.length;
|
|
if (!body) return;
|
|
try {
|
|
await fetch(new URL("v1/cmcd", this.collector), { method: "POST", headers: { "content-type": "application/cmcd" }, body, keepalive: true });
|
|
} catch {
|
|
// Telemetry is advisory; the record binds the log (§7.2).
|
|
}
|
|
}
|
|
|
|
/** The session ends (§8.2): its records signed, and the bundle (§9). `end` defaults to now. */
|
|
async close(ended, end = this.now()) {
|
|
if (this.closed) return this.closed;
|
|
if (this.timer) clearInterval(this.timer);
|
|
await Promise.all(this.pending);
|
|
const presentation = this.presentation ?? LIVE;
|
|
const spans = this.spans.spans(end);
|
|
const acked = await acknowledge(this.key, presentation, this.start, end, this.windowUs, this.received);
|
|
const claims = [];
|
|
for (const a of this.ads.values()) {
|
|
const sp = a.recorder.spans();
|
|
const c = this.creativeOf(a);
|
|
if (sp.length > 0 && c) claims.push({ marker: a.marker, creative: c.objects, spans: sp });
|
|
}
|
|
claims.push(...this.manualClaims);
|
|
const signed = [];
|
|
for (const c of claims) {
|
|
const objects = touched(c.creative, c.spans);
|
|
const bytes = await signClaim(this.key, { channel: this.channel, session: this.session, marker: c.marker, creative: c.creative, objects, spans: c.spans, geometry: c.geometry, slot: c.slot });
|
|
signed.push({ bytes, digest: await sha256(bytes.subarray(0, bytes.length - 64)), marker: c.marker, creative: c.creative });
|
|
}
|
|
const log = JSON.stringify([this.log]);
|
|
const notes = this.notes.length ? JSON.stringify(this.notes) : null;
|
|
const m = sessionMetrics(this.log);
|
|
const origins = [...new Set(this.received.map((r) => r.origin))].sort();
|
|
const atts = await Promise.all(this.attachments.map(async (a) => ({ ...a, digest: await sha256(a.bytes) })));
|
|
const sorted = (ds) => ds.slice().sort(cmp);
|
|
const energy = [...this.energyEntries.values()].sort((a, b) => a.role - b.role);
|
|
const body = concat(
|
|
SESSION_MAGIC, u8(1), this.channel, this.owner, this.key.publicKey, this.session, u8(presentation), u64(this.start), u64(end), u8(ENDED[ended] ?? ended),
|
|
u8(origins.length), ...origins.flatMap((o) => [u16(o.length), enc.encode(o)]),
|
|
u16(acked.acks.length), ...sorted(acked.acks.map((a) => a.digest)),
|
|
u16(signed.length), ...sorted(signed.map((c) => c.digest)),
|
|
u32(spans.length), ...spans.flatMap((s) => [u32(s.startMs), u32(s.lengthMs), u16(s.visibleBp), u8(s.flags)]),
|
|
u32(spans.length ? m.vst ?? NO_STARTUP : NO_STARTUP), u32(m.stalls), u64(m.stallMs), u32(acked.repeatObjects), u64(acked.repeatBytes),
|
|
await sha256(enc.encode(log)), notes ? await sha256(enc.encode(notes)) : ZERO,
|
|
u8(energy.length), ...energy.flatMap((e) => [u8(e.role), u64(e.joules), e.cls]),
|
|
u16(atts.length), ...sorted(atts.map((a) => a.digest)),
|
|
);
|
|
const record = concat(body, await sign(this.key.privateKey, SESSION_DOMAIN, body));
|
|
this.closed = {
|
|
v: 1,
|
|
session: hex(record),
|
|
acks: acked.acks.map((a) => ({ record: hex(a.record), items: a.items.map(hex) })),
|
|
claims: signed.map((c) => ({ claim: hex(c.bytes), marker: c.marker, creative: c.creative.map((o) => ({ content_hash: hex(o.hash), duration_ms: o.durationMs })) })),
|
|
log,
|
|
notes,
|
|
attachments: atts.map((a) => ({ name: a.name, record: hex(a.bytes) })),
|
|
};
|
|
return this.closed;
|
|
}
|
|
|
|
/** Post the bundle to the collector (§12); its answer. `keepalive` for a page that is going away. */
|
|
async send({ keepalive = false } = {}) {
|
|
if (!this.closed || !this.collector) return null;
|
|
await this.flushCmcd();
|
|
const res = await fetch(new URL("v1/sessions", this.collector), { method: "POST", headers: { "content-type": "text/plain;charset=UTF-8" }, body: JSON.stringify(this.closed), keepalive });
|
|
return { status: res.status, body: await res.json().catch(() => null) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observe a media element for a session: every `intervalMs` (at most 200, §5.2) and on its events;
|
|
* the log's first presentation, stalls and end from its `playing`, `waiting` and `ended` events;
|
|
* a fraction of 0 while the page is hidden or out of focus. `programMs()` gives the program time of
|
|
* the position (the playlist's `EXT-X-PROGRAM-DATE-TIME` timeline), or null. On `ended` the session
|
|
* closes and its bundle is sent; on `pagehide` it closes as `user` and is sent with `keepalive`.
|
|
*/
|
|
export function attach(video, session, { programMs = () => null, intervalMs = 200, onBundle = null } = {}) {
|
|
let ratio = 0;
|
|
let seeked = false;
|
|
let presented = false;
|
|
let stalled = false;
|
|
const io = new IntersectionObserver((entries) => {
|
|
for (const e of entries) ratio = e.intersectionRatio;
|
|
}, { threshold: Array.from({ length: 101 }, (_, i) => i / 100) });
|
|
io.observe(video);
|
|
const tick = () => {
|
|
const shown = document.visibilityState === "visible" && document.hasFocus();
|
|
session.observe({
|
|
playing: !video.paused && !video.ended && video.readyState >= 3 && video.playbackRate === 1,
|
|
visibleBp: shown ? ratio * 10_000 : 0,
|
|
audible: !video.muted && video.volume > 0,
|
|
programMs: programMs(),
|
|
seeked,
|
|
});
|
|
seeked = false;
|
|
};
|
|
const onPlaying = () => {
|
|
if (!presented) {
|
|
presented = true;
|
|
session.event("present");
|
|
} else if (stalled) {
|
|
stalled = false;
|
|
session.event("stall-end");
|
|
}
|
|
tick();
|
|
};
|
|
const onWaiting = () => {
|
|
if (presented && !stalled) {
|
|
stalled = true;
|
|
session.event("stall-start");
|
|
}
|
|
tick();
|
|
};
|
|
const finish = async (ended, keepalive) => {
|
|
tick();
|
|
stop();
|
|
session.event("end");
|
|
await session.close(ended);
|
|
const answer = await session.send({ keepalive }).catch((e) => ({ status: 0, body: String(e) }));
|
|
if (onBundle) onBundle(session.closed, answer);
|
|
};
|
|
const onEnded = () => finish("ended", false);
|
|
const onHide = () => finish("user", true);
|
|
const onSeek = () => {
|
|
seeked = true;
|
|
tick();
|
|
};
|
|
const events = ["play", "pause", "volumechange", "ratechange"];
|
|
for (const e of events) video.addEventListener(e, tick);
|
|
video.addEventListener("playing", onPlaying);
|
|
video.addEventListener("waiting", onWaiting);
|
|
video.addEventListener("seeking", onSeek);
|
|
video.addEventListener("ended", onEnded);
|
|
document.addEventListener("visibilitychange", tick);
|
|
window.addEventListener("pagehide", onHide);
|
|
const timer = setInterval(tick, Math.min(intervalMs, 200));
|
|
session.event("start");
|
|
function stop() {
|
|
clearInterval(timer);
|
|
io.disconnect();
|
|
for (const e of events) video.removeEventListener(e, tick);
|
|
video.removeEventListener("playing", onPlaying);
|
|
video.removeEventListener("waiting", onWaiting);
|
|
video.removeEventListener("seeking", onSeek);
|
|
video.removeEventListener("ended", onEnded);
|
|
document.removeEventListener("visibilitychange", tick);
|
|
window.removeEventListener("pagehide", onHide);
|
|
}
|
|
return { stop, finish };
|
|
}
|