diff --git a/src/Util.spec.ts b/src/Util.spec.ts index cb9a6b4..0365fcc 100644 --- a/src/Util.spec.ts +++ b/src/Util.spec.ts @@ -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)); diff --git a/src/Util.ts b/src/Util.ts index de0ce29..2b925c1 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -530,3 +530,28 @@ export function caseInsensitive(obj: any): Record { } }); } + +/** + * 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'); +} diff --git a/src/stats/PlayerStats.spec.ts b/src/stats/PlayerStats.spec.ts index 6299dae..f8b8d39 100644 --- a/src/stats/PlayerStats.spec.ts +++ b/src/stats/PlayerStats.spec.ts @@ -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', () => { @@ -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, @@ -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); }); @@ -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; @@ -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); @@ -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); @@ -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); @@ -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'); @@ -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); }); diff --git a/src/stats/PlayerStats.ts b/src/stats/PlayerStats.ts index 2eedbe0..d416d05 100644 --- a/src/stats/PlayerStats.ts +++ b/src/stats/PlayerStats.ts @@ -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). @@ -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, 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 }