/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.msf.EventTimeline');
goog.require('shaka.log');
goog.require('shaka.util.StringUtils');
goog.requireType('shaka.msf.Utils');
/**
* The records of one MSF event timeline track, and which of them have been
* reported.
*
* An event timeline associates ad hoc metadata with the presentation: each
* record carries a `data` value, whose structure its track's `eventType`
* defines, and exactly one index reference saying where it applies -- a media
* time, a wallclock time or a MoQT Location (MSF section 8.1). This class
* handles what every event type has in common and leaves `data` alone.
*
* Placing a record on the presentation timeline is the caller's business,
* since a Location or a wallclock time needs a media timeline to be placed.
* A record that cannot be placed yet is held until it can, and every record is
* reported once, the first time it can be placed. That matters because the
* publisher repeats every record still accessible at the start of each Group,
* and because where a record is placed can shift as the media timeline grows.
*
* @see https://datatracker.ietf.org/doc/draft-ietf-moq-msf/ section 8
*
* @final
*/
shaka.msf.EventTimeline = class {
/**
* @param {function(shaka.msf.EventTimeline.Record):?number} getTime Places
* a record on the presentation timeline, in seconds, or answers null when
* it cannot be placed yet.
* @param {function(shaka.msf.EventTimeline.Record, number)} onRecord
* Receives each record, once, with where it was placed.
*/
constructor(getTime, onRecord) {
/**
* @private {function(shaka.msf.EventTimeline.Record):?number}
*/
this.getTime_ = getTime;
/** @private {function(shaka.msf.EventTimeline.Record, number)} */
this.onRecord_ = onRecord;
/**
* The IDs of the records already reported and still accessible.
*
* @private {!Set<string>}
*/
this.reported_ = new Set();
/**
* The records waiting to be placed, by ID, in arrival order.
*
* @private {!Map<string, shaka.msf.EventTimeline.Record>}
*/
this.pending_ = new Map();
}
/**
* Takes in one Object of the track, and reports every record that can be
* placed.
*
* The first Object of each Group is independent: it carries every record
* still accessible, so a record missing from it has aged out and will not be
* sent again. The Objects after it in the same Group carry only what is new
* (MSF section 8.3).
*
* @param {!Uint8Array} data The Object payload, a JSON document.
* @param {boolean} independent Whether this is the first Object of a Group.
*/
addObject(data, independent) {
let parsed;
try {
parsed = JSON.parse(shaka.util.StringUtils.fromUTF8(data));
} catch (error) {
shaka.log.warning('Discarding an unparseable event timeline object',
error);
return;
}
if (!Array.isArray(parsed)) {
shaka.log.warning(
'Discarding an event timeline object that is not an array', parsed);
return;
}
const records = [];
let discarded = 0;
for (const item of parsed) {
const record = shaka.msf.EventTimeline.parseRecord_(item);
if (record) {
records.push(record);
} else {
discarded++;
}
}
if (discarded) {
shaka.log.warning(
`Discarded ${discarded} malformed event timeline record(s)`);
}
if (independent && (records.length || !discarded)) {
// Forgetting what has aged out is what keeps these from growing for as
// long as the presentation runs. A document in which nothing was
// readable says nothing about what aged out, so it is not taken as a
// reason to forget.
const current = new Set(records.map((record) => record.id));
for (const id of this.reported_) {
if (!current.has(id)) {
this.reported_.delete(id);
}
}
for (const id of this.pending_.keys()) {
if (!current.has(id)) {
this.pending_.delete(id);
}
}
}
for (const record of records) {
if (this.reported_.has(record.id) || this.pending_.has(record.id)) {
continue;
}
this.pending_.set(record.id, record);
if (this.pending_.size > shaka.msf.EventTimeline.MAX_PENDING_) {
// A record that never becomes placeable -- one whose wallclock time no
// media timeline ever covers, say -- must not be held forever. The
// oldest goes first.
const oldest = this.pending_.keys().next().value;
shaka.log.warning('Discarding an event timeline record that could ' +
'not be placed on the presentation timeline',
this.pending_.get(oldest));
this.pending_.delete(oldest);
}
}
this.resolve();
}
/**
* Reports every pending record that can now be placed, and leaves the
* others waiting. Called when what places them -- a media timeline -- has
* changed.
*/
resolve() {
for (const record of Array.from(this.pending_.values())) {
const time = this.getTime_(record);
if (time == null) {
continue;
}
this.pending_.delete(record.id);
this.reported_.add(record.id);
this.onRecord_(record, time);
}
}
/**
* Reads one record: exactly one index reference and a `data` value.
*
* @param {*} item
* @return {?shaka.msf.EventTimeline.Record}
* @private
*/
static parseRecord_(item) {
if (!item || typeof item != 'object' || Array.isArray(item)) {
return null;
}
const object = /** @type {!Object} */ (item);
if (!('data' in object)) {
return null;
}
const data = object['data'];
// MSF writes the index references as T, L and M, and some of the
// specifications of event types as t, l and m, so either is accepted. A
// record may carry only one.
let index = '';
let value;
for (const key of Object.keys(object)) {
const lower = key.toLowerCase();
if (lower == 'm' || lower == 't' || lower == 'l') {
if (index) {
return null;
}
index = lower;
value = object[key];
}
}
if (!index) {
return null;
}
let time = 0;
/** @type {?shaka.msf.Utils.Location} */
let location = null;
let valueText;
if (index == 'l') {
if (!Array.isArray(value) || value.length < 2 ||
!shaka.msf.EventTimeline.isLocationIndex_(value[0]) ||
!shaka.msf.EventTimeline.isLocationIndex_(value[1])) {
return null;
}
location = {
group: BigInt(value[0]),
object: BigInt(value[1]),
subgroup: null,
};
valueText = `${value[0]},${value[1]}`;
} else {
if (typeof value != 'number' || !isFinite(value) || value < 0) {
return null;
}
time = value;
valueText = String(value);
}
return {
// Two records can share an index reference -- an SCTE-35 splice and
// its cancellation, say -- so what they carry is part of what identifies
// them.
id: `${index}:${valueText}:` +
shaka.msf.EventTimeline.hash_(JSON.stringify(data) || ''),
index,
time,
location,
data,
};
}
/**
* @param {*} value
* @return {boolean} Whether the value can be a Group or Object ID.
* @private
*/
static isLocationIndex_(value) {
return typeof value == 'number' && Number.isInteger(value) && value >= 0;
}
/**
* A short, stable hash of a string (32-bit FNV-1a), for identifiers that
* have to tell long strings apart without carrying them.
*
* @param {string} text
* @return {string}
* @private
*/
static hash_(text) {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}
};
/**
* One event timeline record.
*
* @typedef {{
* id: string,
* index: string,
* time: number,
* location: ?shaka.msf.Utils.Location,
* data: *,
* }}
*
* @property {string} id
* Identifies the record across the documents that repeat it: its index
* reference and a hash of its data.
* @property {string} index
* Which index reference it carries: 'm' for media time, 't' for wallclock
* time or 'l' for Location.
* @property {number} time
* For 'm' the media time, and for 't' the wallclock time, both in
* milliseconds. 0 for 'l'.
* @property {?shaka.msf.Utils.Location} location
* For 'l', the Location. Null otherwise.
* @property {*} data
* The record's `data`, as parsed from JSON. Its structure is defined by the
* track's `eventType`.
*/
shaka.msf.EventTimeline.Record;
/**
* How many records may wait to be placed.
*
* @private @const {number}
*/
shaka.msf.EventTimeline.MAX_PENDING_ = 100;