Home Reference Source

src/controller/cmcd-controller.ts

  1. import {
  2. FragmentLoaderConstructor,
  3. HlsConfig,
  4. PlaylistLoaderConstructor,
  5. } from '../config';
  6. import { Events } from '../events';
  7. import Hls, { Fragment } from '../hls';
  8. import {
  9. CMCD,
  10. CMCDHeaders,
  11. CMCDObjectType,
  12. CMCDStreamingFormat,
  13. CMCDVersion,
  14. } from '../types/cmcd';
  15. import { ComponentAPI } from '../types/component-api';
  16. import { BufferCreatedData, MediaAttachedData } from '../types/events';
  17. import {
  18. FragmentLoaderContext,
  19. Loader,
  20. LoaderCallbacks,
  21. LoaderConfiguration,
  22. LoaderContext,
  23. PlaylistLoaderContext,
  24. } from '../types/loader';
  25. import { BufferHelper } from '../utils/buffer-helper';
  26. import { logger } from '../utils/logger';
  27.  
  28. /**
  29. * Controller to deal with Common Media Client Data (CMCD)
  30. * @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf
  31. */
  32. export default class CMCDController implements ComponentAPI {
  33. private hls: Hls;
  34. private config: HlsConfig;
  35. private media?: HTMLMediaElement;
  36. private sid?: string;
  37. private cid?: string;
  38. private useHeaders: boolean = false;
  39. private initialized: boolean = false;
  40. private starved: boolean = false;
  41. private buffering: boolean = true;
  42. private audioBuffer?: SourceBuffer; // eslint-disable-line no-restricted-globals
  43. private videoBuffer?: SourceBuffer; // eslint-disable-line no-restricted-globals
  44.  
  45. constructor(hls: Hls) {
  46. this.hls = hls;
  47. const config = (this.config = hls.config);
  48. const { cmcd } = config;
  49.  
  50. if (cmcd != null) {
  51. config.pLoader = this.createPlaylistLoader();
  52. config.fLoader = this.createFragmentLoader();
  53.  
  54. this.sid = cmcd.sessionId || CMCDController.uuid();
  55. this.cid = cmcd.contentId;
  56. this.useHeaders = cmcd.useHeaders === true;
  57. this.registerListeners();
  58. }
  59. }
  60.  
  61. private registerListeners() {
  62. const hls = this.hls;
  63. hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
  64. hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
  65. hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this);
  66. }
  67.  
  68. private unregisterListeners() {
  69. const hls = this.hls;
  70. hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
  71. hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
  72. hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this);
  73.  
  74. this.onMediaDetached();
  75. }
  76.  
  77. destroy() {
  78. this.unregisterListeners();
  79.  
  80. // @ts-ignore
  81. this.hls = this.config = this.audioBuffer = this.videoBuffer = null;
  82. }
  83.  
  84. private onMediaAttached(
  85. event: Events.MEDIA_ATTACHED,
  86. data: MediaAttachedData
  87. ) {
  88. this.media = data.media;
  89. this.media.addEventListener('waiting', this.onWaiting);
  90. this.media.addEventListener('playing', this.onPlaying);
  91. }
  92.  
  93. private onMediaDetached() {
  94. if (!this.media) {
  95. return;
  96. }
  97.  
  98. this.media.removeEventListener('waiting', this.onWaiting);
  99. this.media.removeEventListener('playing', this.onPlaying);
  100.  
  101. // @ts-ignore
  102. this.media = null;
  103. }
  104.  
  105. private onBufferCreated(
  106. event: Events.BUFFER_CREATED,
  107. data: BufferCreatedData
  108. ) {
  109. this.audioBuffer = data.tracks.audio?.buffer;
  110. this.videoBuffer = data.tracks.video?.buffer;
  111. }
  112.  
  113. private onWaiting = () => {
  114. if (this.initialized) {
  115. this.starved = true;
  116. }
  117.  
  118. this.buffering = true;
  119. };
  120.  
  121. private onPlaying = () => {
  122. if (!this.initialized) {
  123. this.initialized = true;
  124. }
  125.  
  126. this.buffering = false;
  127. };
  128.  
  129. /**
  130. * Create baseline CMCD data
  131. */
  132. private createData(): CMCD {
  133. return {
  134. v: CMCDVersion,
  135. sf: CMCDStreamingFormat.HLS,
  136. sid: this.sid,
  137. cid: this.cid,
  138. pr: this.media?.playbackRate,
  139. mtp: this.hls.bandwidthEstimate / 1000,
  140. };
  141. }
  142.  
  143. /**
  144. * Apply CMCD data to a request.
  145. */
  146. private apply(context: LoaderContext, data: CMCD = {}) {
  147. // apply baseline data
  148. Object.assign(data, this.createData());
  149.  
  150. const isVideo =
  151. data.ot === CMCDObjectType.INIT ||
  152. data.ot === CMCDObjectType.VIDEO ||
  153. data.ot === CMCDObjectType.MUXED;
  154.  
  155. if (this.starved && isVideo) {
  156. data.bs = true;
  157. data.su = true;
  158. this.starved = false;
  159. }
  160.  
  161. if (data.su == null) {
  162. data.su = this.buffering;
  163. }
  164.  
  165. // TODO: Implement rtp, nrr, nor, dl
  166.  
  167. if (this.useHeaders) {
  168. const headers = CMCDController.toHeaders(data);
  169. if (!Object.keys(headers).length) {
  170. return;
  171. }
  172.  
  173. if (!context.headers) {
  174. context.headers = {};
  175. }
  176.  
  177. Object.assign(context.headers, headers);
  178. } else {
  179. const query = CMCDController.toQuery(data);
  180. if (!query) {
  181. return;
  182. }
  183.  
  184. context.url = CMCDController.appendQueryToUri(context.url, query);
  185. }
  186. }
  187.  
  188. /**
  189. * Apply CMCD data to a manifest request.
  190. */
  191. private applyPlaylistData = (context: PlaylistLoaderContext) => {
  192. try {
  193. this.apply(context, {
  194. ot: CMCDObjectType.MANIFEST,
  195. su: !this.initialized,
  196. });
  197. } catch (error) {
  198. logger.warn('Could not generate manifest CMCD data.', error);
  199. }
  200. };
  201.  
  202. /**
  203. * Apply CMCD data to a segment request
  204. */
  205. private applyFragmentData = (context: FragmentLoaderContext) => {
  206. try {
  207. const fragment = context.frag;
  208. const level = this.hls.levels[fragment.level];
  209. const ot = this.getObjectType(fragment);
  210. const data: CMCD = {
  211. d: fragment.duration * 1000,
  212. ot,
  213. };
  214.  
  215. if (
  216. ot === CMCDObjectType.VIDEO ||
  217. ot === CMCDObjectType.AUDIO ||
  218. ot == CMCDObjectType.MUXED
  219. ) {
  220. data.br = level.bitrate / 1000;
  221. data.tb = this.getTopBandwidth(ot) / 1000;
  222. data.bl = this.getBufferLength(ot);
  223. }
  224.  
  225. this.apply(context, data);
  226. } catch (error) {
  227. logger.warn('Could not generate segment CMCD data.', error);
  228. }
  229. };
  230.  
  231. /**
  232. * The CMCD object type.
  233. */
  234. private getObjectType(fragment: Fragment): CMCDObjectType | undefined {
  235. const { type } = fragment;
  236.  
  237. if (type === 'subtitle') {
  238. return CMCDObjectType.TIMED_TEXT;
  239. }
  240.  
  241. if (fragment.sn === 'initSegment') {
  242. return CMCDObjectType.INIT;
  243. }
  244.  
  245. if (type === 'audio') {
  246. return CMCDObjectType.AUDIO;
  247. }
  248.  
  249. if (type === 'main') {
  250. if (!this.hls.audioTracks.length) {
  251. return CMCDObjectType.MUXED;
  252. }
  253.  
  254. return CMCDObjectType.VIDEO;
  255. }
  256.  
  257. return undefined;
  258. }
  259.  
  260. /**
  261. * Get the highest bitrate.
  262. */
  263. private getTopBandwidth(type: CMCDObjectType) {
  264. let bitrate: number = 0;
  265. let levels;
  266. const hls = this.hls;
  267.  
  268. if (type === CMCDObjectType.AUDIO) {
  269. levels = hls.audioTracks;
  270. } else {
  271. const max = hls.maxAutoLevel;
  272. const len = max > -1 ? max + 1 : hls.levels.length;
  273. levels = hls.levels.slice(0, len);
  274. }
  275.  
  276. for (const level of levels) {
  277. if (level.bitrate > bitrate) {
  278. bitrate = level.bitrate;
  279. }
  280. }
  281.  
  282. return bitrate > 0 ? bitrate : NaN;
  283. }
  284.  
  285. /**
  286. * Get the buffer length for a media type in milliseconds
  287. */
  288. private getBufferLength(type: CMCDObjectType) {
  289. const media = this.hls.media;
  290. const buffer =
  291. type === CMCDObjectType.AUDIO ? this.audioBuffer : this.videoBuffer;
  292.  
  293. if (!buffer || !media) {
  294. return NaN;
  295. }
  296.  
  297. const info = BufferHelper.bufferInfo(
  298. buffer,
  299. media.currentTime,
  300. this.config.maxBufferHole
  301. );
  302.  
  303. return info.len * 1000;
  304. }
  305.  
  306. /**
  307. * Create a playlist loader
  308. */
  309. private createPlaylistLoader(): PlaylistLoaderConstructor | undefined {
  310. const { pLoader } = this.config;
  311. const apply = this.applyPlaylistData;
  312. const Ctor = pLoader || (this.config.loader as PlaylistLoaderConstructor);
  313.  
  314. return class CmcdPlaylistLoader {
  315. private loader: Loader<PlaylistLoaderContext>;
  316.  
  317. constructor(config: HlsConfig) {
  318. this.loader = new Ctor(config);
  319. }
  320.  
  321. get stats() {
  322. return this.loader.stats;
  323. }
  324.  
  325. get context() {
  326. return this.loader.context;
  327. }
  328.  
  329. destroy() {
  330. this.loader.destroy();
  331. }
  332.  
  333. abort() {
  334. this.loader.abort();
  335. }
  336.  
  337. load(
  338. context: PlaylistLoaderContext,
  339. config: LoaderConfiguration,
  340. callbacks: LoaderCallbacks<PlaylistLoaderContext>
  341. ) {
  342. apply(context);
  343. this.loader.load(context, config, callbacks);
  344. }
  345. };
  346. }
  347.  
  348. /**
  349. * Create a playlist loader
  350. */
  351. private createFragmentLoader(): FragmentLoaderConstructor | undefined {
  352. const { fLoader } = this.config;
  353. const apply = this.applyFragmentData;
  354. const Ctor = fLoader || (this.config.loader as FragmentLoaderConstructor);
  355.  
  356. return class CmcdFragmentLoader {
  357. private loader: Loader<FragmentLoaderContext>;
  358.  
  359. constructor(config: HlsConfig) {
  360. this.loader = new Ctor(config);
  361. }
  362.  
  363. get stats() {
  364. return this.loader.stats;
  365. }
  366.  
  367. get context() {
  368. return this.loader.context;
  369. }
  370.  
  371. destroy() {
  372. this.loader.destroy();
  373. }
  374.  
  375. abort() {
  376. this.loader.abort();
  377. }
  378.  
  379. load(
  380. context: FragmentLoaderContext,
  381. config: LoaderConfiguration,
  382. callbacks: LoaderCallbacks<FragmentLoaderContext>
  383. ) {
  384. apply(context);
  385. this.loader.load(context, config, callbacks);
  386. }
  387. };
  388. }
  389.  
  390. /**
  391. * Generate a random v4 UUI
  392. *
  393. * @returns {string}
  394. */
  395. static uuid(): string {
  396. const url = URL.createObjectURL(new Blob());
  397. const uuid = url.toString();
  398. URL.revokeObjectURL(url);
  399. return uuid.slice(uuid.lastIndexOf('/') + 1);
  400. }
  401.  
  402. /**
  403. * Serialize a CMCD data object according to the rules defined in the
  404. * section 3.2 of
  405. * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf).
  406. */
  407. static serialize(data: CMCD): string {
  408. const results: string[] = [];
  409. const isValid = (value: any) =>
  410. !Number.isNaN(value) && value != null && value !== '' && value !== false;
  411. const toRounded = (value: number) => Math.round(value);
  412. const toHundred = (value: number) => toRounded(value / 100) * 100;
  413. const toUrlSafe = (value: string) => encodeURIComponent(value);
  414. const formatters = {
  415. br: toRounded,
  416. d: toRounded,
  417. bl: toHundred,
  418. dl: toHundred,
  419. mtp: toHundred,
  420. nor: toUrlSafe,
  421. rtp: toHundred,
  422. tb: toRounded,
  423. };
  424.  
  425. const keys = Object.keys(data || {}).sort();
  426.  
  427. for (const key of keys) {
  428. let value = data[key];
  429.  
  430. // ignore invalid values
  431. if (!isValid(value)) {
  432. continue;
  433. }
  434.  
  435. // Version should only be reported if not equal to 1.
  436. if (key === 'v' && value === 1) {
  437. continue;
  438. }
  439.  
  440. // Playback rate should only be sent if not equal to 1.
  441. if (key == 'pr' && value === 1) {
  442. continue;
  443. }
  444.  
  445. // Certain values require special formatting
  446. const formatter = formatters[key];
  447. if (formatter) {
  448. value = formatter(value);
  449. }
  450.  
  451. // Serialize the key/value pair
  452. const type = typeof value;
  453. let result: string;
  454.  
  455. if (key === 'ot' || key === 'sf' || key === 'st') {
  456. result = `${key}=${value}`;
  457. } else if (type === 'boolean') {
  458. result = key;
  459. } else if (type === 'number') {
  460. result = `${key}=${value}`;
  461. } else {
  462. result = `${key}=${JSON.stringify(value)}`;
  463. }
  464.  
  465. results.push(result);
  466. }
  467.  
  468. return results.join(',');
  469. }
  470.  
  471. /**
  472. * Convert a CMCD data object to request headers according to the rules
  473. * defined in the section 2.1 and 3.2 of
  474. * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf).
  475. */
  476. static toHeaders(data: CMCD): Partial<CMCDHeaders> {
  477. const keys = Object.keys(data);
  478. const headers = {};
  479. const headerNames = ['Object', 'Request', 'Session', 'Status'];
  480. const headerGroups = [{}, {}, {}, {}];
  481. const headerMap = {
  482. br: 0,
  483. d: 0,
  484. ot: 0,
  485. tb: 0,
  486. bl: 1,
  487. dl: 1,
  488. mtp: 1,
  489. nor: 1,
  490. nrr: 1,
  491. su: 1,
  492. cid: 2,
  493. pr: 2,
  494. sf: 2,
  495. sid: 2,
  496. st: 2,
  497. v: 2,
  498. bs: 3,
  499. rtp: 3,
  500. };
  501.  
  502. for (const key of keys) {
  503. // Unmapped fields are mapped to the Request header
  504. const index = headerMap[key] != null ? headerMap[key] : 1;
  505. headerGroups[index][key] = data[key];
  506. }
  507.  
  508. for (let i = 0; i < headerGroups.length; i++) {
  509. const value = CMCDController.serialize(headerGroups[i]);
  510. if (value) {
  511. headers[`CMCD-${headerNames[i]}`] = value;
  512. }
  513. }
  514.  
  515. return headers;
  516. }
  517.  
  518. /**
  519. * Convert a CMCD data object to query args according to the rules
  520. * defined in the section 2.2 and 3.2 of
  521. * [CTA-5004](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf).
  522. */
  523. static toQuery(data: CMCD): string {
  524. return `CMCD=${encodeURIComponent(CMCDController.serialize(data))}`;
  525. }
  526.  
  527. /**
  528. * Append query args to a uri.
  529. */
  530. static appendQueryToUri(uri, query) {
  531. if (!query) {
  532. return uri;
  533. }
  534.  
  535. const separator = uri.includes('?') ? '&' : '?';
  536. return `${uri}${separator}${query}`;
  537. }
  538. }