Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ describe('Util', () => {
});
});

describe('hash', () => {
it('should return a deterministic 14-character hexadecimal hash', () => {
expect(Util.hash('Hello 😭')).toBe('127c8d5f428cf2');
expect(Util.hash('Hello 😭')).toBe(Util.hash('Hello 😭'));
expect(Util.hash('Hello 😭')).toMatch(/^[0-9a-f]{14}$/);
});
});

describe('safePromise', () => {
it('should resolve before timeout', async () => {
const promise = new Promise(resolve => setTimeout(() => resolve('success'), 100));
Expand Down
25 changes: 25 additions & 0 deletions src/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,28 @@ export function caseInsensitive(obj: any): Record<string, any> {
}
});
}

/**
* Fast, deterministic, non-cryptographic hash function for strings.
*
* Produces a 53-bit hash encoded as a 14-character hexadecimal string.
* Useful for generating stable identifiers from strings with lower overhead
* than a cryptographic hash.
*
* @note This function is designed for speed and consistency, not security.
* It must not be used as a substitute for a cryptographic hash function.
*/
export function hash(value: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;

for (let i = 0; i < value.length; i += 1) {
const ch = value.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const hash = 4294967296 * (h2 & 0x1fffff) + (h1 >>> 0);
return hash.toString(16).padStart(14, '0');
}
41 changes: 28 additions & 13 deletions src/stats/PlayerStats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import * as CML from '@svta/common-media-library';
import { PlayerStats } from './PlayerStats';
import * as Util from '../Util';

describe('PlayerStats', () => {
describe('toCmcd', () => {
Expand All @@ -28,12 +29,12 @@ describe('PlayerStats', () => {
prev.stallCount = 2;

const url = new URL('https://example.com/live/manifest.m3u8?token=abc');
const cmcd = stats.toCmcd(url, 1, prev);
const cmcd = stats.toCmcd(url, [1], prev);

expect(cmcd).toMatchObject({
ot: CML.CmcdObjectType.VIDEO,
st: CML.CmcdStreamType.LIVE,
cid: 'manifest.m3u8',
cid: Util.hash(url.pathname),
dl: 1500,
br: 1500,
bs: true,
Expand All @@ -50,7 +51,7 @@ describe('PlayerStats', () => {
stats.dataByteRate = 512;

const url = new URL('https://example.com/live/chunk.ts');
const cmcd = stats.toCmcd(url, 99);
const cmcd = stats.toCmcd(url, [99]);

expect(cmcd.mtp).toBe(512);
});
Expand All @@ -63,12 +64,26 @@ describe('PlayerStats', () => {
stats.videoTrackBandwidth = 1800;

const url = new URL('https://example.com/live/chunk.ts');
const cmcd = stats.toCmcd(url, 2);
const cmcd = stats.toCmcd(url, [2]);

expect(cmcd.ot).toBe(CML.CmcdObjectType.AUDIO);
expect(cmcd.br).toBe(96);
});

it('should use MUXED object type and combined bitrate when audio and video tracks are both selected', () => {
const stats = new PlayerStats();
stats.audioTrackId = 2;
stats.audioTrackBandwidth = 96;
stats.videoTrackId = 1;
stats.videoTrackBandwidth = 1800;

const url = new URL('https://example.com/live/chunk.ts');
const cmcd = stats.toCmcd(url, [1, 2]);

expect(cmcd.ot).toBe(CML.CmcdObjectType.MUXED);
expect(cmcd.br).toBe(96 + 1800);
});

it('should sum bitrates and use OTHER object type when trackId does not match', () => {
const stats = new PlayerStats();
stats.audioTrackId = 2;
Expand All @@ -77,7 +92,7 @@ describe('PlayerStats', () => {
stats.videoTrackBandwidth = 1800;

const url = new URL('https://example.com/live/chunk.ts');
const cmcd = stats.toCmcd(url, 99);
const cmcd = stats.toCmcd(url, [99]);

expect(cmcd.ot).toBe(CML.CmcdObjectType.OTHER);
expect(cmcd.br).toBe(96 + 1800);
Expand All @@ -90,7 +105,7 @@ describe('PlayerStats', () => {
stats.bufferAmount = 1000;

const url = new URL('https://example.com/v/seg.ts');
const cmcd = stats.toCmcd(url, 1);
const cmcd = stats.toCmcd(url, [1]);

expect(cmcd.pr).toBe(1.23);
expect(cmcd.dl).toBe(1234);
Expand All @@ -102,7 +117,7 @@ describe('PlayerStats', () => {
stats.bufferAmount = 500;

const url = new URL('https://example.com/v/seg.ts');
const cmcd = stats.toCmcd(url, 1);
const cmcd = stats.toCmcd(url, [1]);

expect(cmcd.pr).toBe(2);
expect(cmcd.dl).toBe(1000);
Expand All @@ -113,25 +128,25 @@ describe('PlayerStats', () => {

const dash = new PlayerStats();
dash.protocol = 'DASH';
expect(dash.toCmcd(url, 1).sf).toBe('d');
expect(dash.toCmcd(url, [1]).sf).toBe('d');

const smooth = new PlayerStats();
smooth.protocol = 'smooth';
expect(smooth.toCmcd(url, 1).sf).toBe('s');
expect(smooth.toCmcd(url, [1]).sf).toBe('s');

const unknown = new PlayerStats();
unknown.protocol = 'WRTS';
expect(unknown.toCmcd(url, 1).sf).toBe('o');
expect(unknown.toCmcd(url, [1]).sf).toBe('o');
});

it('should not set optional fields when their source values are undefined', () => {
const stats = new PlayerStats();
const url = new URL('https://example.com/live/seg.ts');
const cmcd = stats.toCmcd(url, 1);
const cmcd = stats.toCmcd(url, [1]);

expect(cmcd).toMatchObject({
st: CML.CmcdStreamType.LIVE,
cid: 'seg.ts'
cid: Util.hash(url.pathname)
});
expect(cmcd).not.toHaveProperty('sf');
expect(cmcd).not.toHaveProperty('su');
Expand All @@ -149,7 +164,7 @@ describe('PlayerStats', () => {
prev.stallCount = 5;

const url = new URL('https://example.com/live/seg.ts');
const cmcd = stats.toCmcd(url, 1, prev);
const cmcd = stats.toCmcd(url, [1], prev);

expect(cmcd.bs).toBe(false);
});
Expand Down
44 changes: 29 additions & 15 deletions src/stats/PlayerStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import * as CML from '@svta/common-media-library';
import { Loggable } from '../Log';
import * as Util from '../Util';

/**
* Collects variable names for player statistics metrics across different projects (e.g., wrts, webrtc).
Expand Down Expand Up @@ -51,36 +52,49 @@ export class PlayerStats extends Loggable {
/**
* Converts the current {@link PlayerStats} snapshot into a CMCD (Common Media Client Data) payload.
* @param url - The full URL of the media object.
* @param trackId - The track ID for which to generate the CMCD payload.
* @param trackIds -Track Id to generate the CMCD payload, keep empty to relate an CMCD OTHER object.
* @param prevStats - Optional previous {@link PlayerStats} snapshot to calculate deltas for incremental metrics since their last reset.
* @returns A {@link CML.Cmcd} object representing the CMCD payload.
*/
toCmcd(url: URL, trackId: number, prevStats?: PlayerStats): CML.Cmcd {
toCmcd(url: URL, trackIds: Array<number>, prevStats?: PlayerStats): CML.Cmcd {
const cmcd: CML.Cmcd = {};
// Determine playback rate to use, preferring 'playbackRate' if available, otherwise falling back to 'playbackSpeed'
const playBack = this.playbackRate ?? this.playbackSpeed;

// Object Type
if (trackId === this.audioTrackId) {
cmcd.ot = CML.CmcdObjectType.AUDIO;
} else if (trackId === this.videoTrackId) {
let hasVideo = false;
let hasAudio = false;
for (const trackId of trackIds) {
if (trackId === this.audioTrackId) {
hasAudio = true;
} else if (trackId === this.videoTrackId) {
hasVideo = true;
}
}

// Determine object type and set 'br' (bitrate): use audio, video, or sum of both depending on selected tracks
cmcd.br = (this.audioTrackBandwidth ?? 0) + (this.videoTrackBandwidth ?? 0);
if (hasAudio) {
if (hasVideo) {
cmcd.ot = CML.CmcdObjectType.MUXED;
} else {
// just audio
cmcd.ot = CML.CmcdObjectType.AUDIO;
cmcd.br = this.audioTrackBandwidth ?? 0;
}
} else if (hasVideo) {
// just video
cmcd.ot = CML.CmcdObjectType.VIDEO;
cmcd.br = this.videoTrackBandwidth ?? 0;
} else {
cmcd.ot = CML.CmcdObjectType.OTHER;
}

cmcd.st = CML.CmcdStreamType.LIVE; // Stream Type
cmcd.cid = url.pathname.split('/').pop(); // Content ID
cmcd.cid = Util.hash(url.pathname); // Content ID
if (this.bufferAmount != null && playBack != null) {
cmcd.dl = this.bufferAmount * playBack; // Deadline
}
// br is computed to be only for video, or only audio track, or sum of both depending of if trackId matches either audio or video track IDs
if (trackId === this.videoTrackId) {
cmcd.br = this.videoTrackBandwidth ?? 0;
} else if (trackId === this.audioTrackId) {
cmcd.br = this.audioTrackBandwidth ?? 0;
} else {
cmcd.br = (this.audioTrackBandwidth ?? 0) + (this.videoTrackBandwidth ?? 0);
}

if (this.stallCount != null) {
cmcd.bs = this.stallCount - (prevStats?.stallCount ?? 0) > 0; // Buffer Starvation
}
Expand Down
Loading