Home Reference Source

src/loader/m3u8-parser.ts

  1. import * as URLToolkit from 'url-toolkit';
  2.  
  3. import { Fragment, Part } from './fragment';
  4. import { LevelDetails } from './level-details';
  5. import { LevelKey } from './level-key';
  6.  
  7. import { AttrList } from '../utils/attr-list';
  8. import { logger } from '../utils/logger';
  9. import type { CodecType } from '../utils/codecs';
  10. import { isCodecType } from '../utils/codecs';
  11. import type {
  12. MediaPlaylist,
  13. AudioGroup,
  14. MediaPlaylistType,
  15. } from '../types/media-playlist';
  16. import type { PlaylistLevelType } from '../types/loader';
  17. import type { LevelAttributes, LevelParsed } from '../types/level';
  18.  
  19. type M3U8ParserFragments = Array<Fragment | null>;
  20.  
  21. // https://regex101.com is your friend
  22. const MASTER_PLAYLIST_REGEX = /#EXT-X-STREAM-INF:([^\r\n]*)(?:[\r\n](?:#[^\r\n]*)?)*([^\r\n]+)|#EXT-X-SESSION-DATA:([^\r\n]*)[\r\n]+/g;
  23. const MASTER_PLAYLIST_MEDIA_REGEX = /#EXT-X-MEDIA:(.*)/g;
  24.  
  25. const LEVEL_PLAYLIST_REGEX_FAST = new RegExp(
  26. [
  27. /#EXTINF:\s*(\d*(?:\.\d+)?)(?:,(.*)\s+)?/.source, // duration (#EXTINF:<duration>,<title>), group 1 => duration, group 2 => title
  28. /(?!#) *(\S[\S ]*)/.source, // segment URI, group 3 => the URI (note newline is not eaten)
  29. /#EXT-X-BYTERANGE:*(.+)/.source, // next segment's byterange, group 4 => range spec (x@y)
  30. /#EXT-X-PROGRAM-DATE-TIME:(.+)/.source, // next segment's program date/time group 5 => the datetime spec
  31. /#.*/.source, // All other non-segment oriented tags will match with all groups empty
  32. ].join('|'),
  33. 'g'
  34. );
  35.  
  36. const LEVEL_PLAYLIST_REGEX_SLOW = new RegExp(
  37. [
  38. /#(EXTM3U)/.source,
  39. /#EXT-X-(PLAYLIST-TYPE):(.+)/.source,
  40. /#EXT-X-(MEDIA-SEQUENCE): *(\d+)/.source,
  41. /#EXT-X-(SKIP):(.+)/.source,
  42. /#EXT-X-(TARGETDURATION): *(\d+)/.source,
  43. /#EXT-X-(KEY):(.+)/.source,
  44. /#EXT-X-(START):(.+)/.source,
  45. /#EXT-X-(ENDLIST)/.source,
  46. /#EXT-X-(DISCONTINUITY-SEQ)UENCE: *(\d+)/.source,
  47. /#EXT-X-(DIS)CONTINUITY/.source,
  48. /#EXT-X-(VERSION):(\d+)/.source,
  49. /#EXT-X-(MAP):(.+)/.source,
  50. /#EXT-X-(SERVER-CONTROL):(.+)/.source,
  51. /#EXT-X-(PART-INF):(.+)/.source,
  52. /#EXT-X-(GAP)/.source,
  53. /#EXT-X-(BITRATE):\s*(\d+)/.source,
  54. /#EXT-X-(PART):(.+)/.source,
  55. /#EXT-X-(PRELOAD-HINT):(.+)/.source,
  56. /#EXT-X-(RENDITION-REPORT):(.+)/.source,
  57. /(#)([^:]*):(.*)/.source,
  58. /(#)(.*)(?:.*)\r?\n?/.source,
  59. ].join('|')
  60. );
  61.  
  62. const MP4_REGEX_SUFFIX = /\.(mp4|m4s|m4v|m4a)$/i;
  63.  
  64. export default class M3U8Parser {
  65. static findGroup(
  66. groups: Array<AudioGroup>,
  67. mediaGroupId: string
  68. ): AudioGroup | undefined {
  69. for (let i = 0; i < groups.length; i++) {
  70. const group = groups[i];
  71. if (group.id === mediaGroupId) {
  72. return group;
  73. }
  74. }
  75. }
  76.  
  77. static convertAVC1ToAVCOTI(codec) {
  78. // Convert avc1 codec string from RFC-4281 to RFC-6381 for MediaSource.isTypeSupported
  79. const avcdata = codec.split('.');
  80. if (avcdata.length > 2) {
  81. let result = avcdata.shift() + '.';
  82. result += parseInt(avcdata.shift()).toString(16);
  83. result += ('000' + parseInt(avcdata.shift()).toString(16)).substr(-4);
  84. return result;
  85. }
  86. return codec;
  87. }
  88.  
  89. static resolve(url, baseUrl) {
  90. return URLToolkit.buildAbsoluteURL(baseUrl, url, { alwaysNormalize: true });
  91. }
  92.  
  93. static parseMasterPlaylist(string: string, baseurl: string) {
  94. const levels: Array<LevelParsed> = [];
  95. const sessionData: Record<string, AttrList> = {};
  96. let hasSessionData = false;
  97. MASTER_PLAYLIST_REGEX.lastIndex = 0;
  98.  
  99. let result: RegExpExecArray | null;
  100. while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) {
  101. if (result[1]) {
  102. // '#EXT-X-STREAM-INF' is found, parse level tag in group 1
  103. const attrs = new AttrList(result[1]);
  104. const level: LevelParsed = {
  105. attrs,
  106. bitrate:
  107. attrs.decimalInteger('AVERAGE-BANDWIDTH') ||
  108. attrs.decimalInteger('BANDWIDTH'),
  109. name: attrs.NAME,
  110. url: M3U8Parser.resolve(result[2], baseurl),
  111. };
  112.  
  113. const resolution = attrs.decimalResolution('RESOLUTION');
  114. if (resolution) {
  115. level.width = resolution.width;
  116. level.height = resolution.height;
  117. }
  118.  
  119. setCodecs(
  120. (attrs.CODECS || '').split(/[ ,]+/).filter((c) => c),
  121. level
  122. );
  123.  
  124. if (level.videoCodec && level.videoCodec.indexOf('avc1') !== -1) {
  125. level.videoCodec = M3U8Parser.convertAVC1ToAVCOTI(level.videoCodec);
  126. }
  127.  
  128. levels.push(level);
  129. } else if (result[3]) {
  130. // '#EXT-X-SESSION-DATA' is found, parse session data in group 3
  131. const sessionAttrs = new AttrList(result[3]);
  132. if (sessionAttrs['DATA-ID']) {
  133. hasSessionData = true;
  134. sessionData[sessionAttrs['DATA-ID']] = sessionAttrs;
  135. }
  136. }
  137. }
  138. return {
  139. levels,
  140. sessionData: hasSessionData ? sessionData : null,
  141. };
  142. }
  143.  
  144. static parseMasterPlaylistMedia(
  145. string: string,
  146. baseurl: string,
  147. type: MediaPlaylistType,
  148. groups: Array<AudioGroup> = []
  149. ): Array<MediaPlaylist> {
  150. let result: RegExpExecArray | null;
  151. const medias: Array<MediaPlaylist> = [];
  152. let id = 0;
  153. MASTER_PLAYLIST_MEDIA_REGEX.lastIndex = 0;
  154. while ((result = MASTER_PLAYLIST_MEDIA_REGEX.exec(string)) !== null) {
  155. const attrs = new AttrList(result[1]) as LevelAttributes;
  156. if (attrs.TYPE === type) {
  157. const media: MediaPlaylist = {
  158. attrs,
  159. bitrate: 0,
  160. id: id++,
  161. groupId: attrs['GROUP-ID'],
  162. instreamId: attrs['INSTREAM-ID'],
  163. name: attrs.NAME || attrs.LANGUAGE || '',
  164. type,
  165. default: attrs.bool('DEFAULT'),
  166. autoselect: attrs.bool('AUTOSELECT'),
  167. forced: attrs.bool('FORCED'),
  168. lang: attrs.LANGUAGE,
  169. url: attrs.URI ? M3U8Parser.resolve(attrs.URI, baseurl) : '',
  170. };
  171.  
  172. if (groups.length) {
  173. // If there are audio or text groups signalled in the manifest, let's look for a matching codec string for this track
  174. // If we don't find the track signalled, lets use the first audio groups codec we have
  175. // Acting as a best guess
  176. const groupCodec =
  177. M3U8Parser.findGroup(groups, media.groupId as string) || groups[0];
  178. assignCodec(media, groupCodec, 'audioCodec');
  179. assignCodec(media, groupCodec, 'textCodec');
  180. }
  181.  
  182. medias.push(media);
  183. }
  184. }
  185. return medias;
  186. }
  187.  
  188. static parseLevelPlaylist(
  189. string: string,
  190. baseurl: string,
  191. id: number,
  192. type: PlaylistLevelType,
  193. levelUrlId: number
  194. ): LevelDetails {
  195. const level = new LevelDetails(baseurl);
  196. const fragments: M3U8ParserFragments = level.fragments;
  197. let currentSN = 0;
  198. let currentPart = 0;
  199. let totalduration = 0;
  200. let discontinuityCounter = 0;
  201. let prevFrag: Fragment | null = null;
  202. let frag: Fragment = new Fragment(type, baseurl);
  203. let result: RegExpExecArray | RegExpMatchArray | null;
  204. let i: number;
  205. let levelkey: LevelKey | undefined;
  206. let firstPdtIndex = -1;
  207.  
  208. LEVEL_PLAYLIST_REGEX_FAST.lastIndex = 0;
  209. level.m3u8 = string;
  210.  
  211. while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) {
  212. const duration = result[1];
  213. if (duration) {
  214. // INF
  215. frag.duration = parseFloat(duration);
  216. // avoid sliced strings https://github.com/video-dev/hls.js/issues/939
  217. const title = (' ' + result[2]).slice(1);
  218. frag.title = title || null;
  219. frag.tagList.push(title ? ['INF', duration, title] : ['INF', duration]);
  220. } else if (result[3]) {
  221. // url
  222. if (Number.isFinite(frag.duration)) {
  223. frag.start = totalduration;
  224. if (levelkey) {
  225. frag.levelkey = levelkey;
  226. }
  227. frag.sn = currentSN;
  228. frag.level = id;
  229. frag.cc = discontinuityCounter;
  230. frag.urlId = levelUrlId;
  231. fragments.push(frag);
  232. // avoid sliced strings https://github.com/video-dev/hls.js/issues/939
  233. frag.relurl = (' ' + result[3]).slice(1);
  234. assignProgramDateTime(frag, prevFrag);
  235. prevFrag = frag;
  236. totalduration += frag.duration;
  237. currentSN++;
  238. currentPart = 0;
  239.  
  240. frag = new Fragment(type, baseurl);
  241. // setup the next fragment for part loading
  242. frag.start = totalduration;
  243. frag.sn = currentSN;
  244. frag.cc = discontinuityCounter;
  245. frag.level = id;
  246. }
  247. } else if (result[4]) {
  248. // X-BYTERANGE
  249. const data = (' ' + result[4]).slice(1);
  250. if (prevFrag) {
  251. frag.setByteRange(data, prevFrag);
  252. } else {
  253. frag.setByteRange(data);
  254. }
  255. } else if (result[5]) {
  256. // PROGRAM-DATE-TIME
  257. // avoid sliced strings https://github.com/video-dev/hls.js/issues/939
  258. frag.rawProgramDateTime = (' ' + result[5]).slice(1);
  259. frag.tagList.push(['PROGRAM-DATE-TIME', frag.rawProgramDateTime]);
  260. if (firstPdtIndex === -1) {
  261. firstPdtIndex = fragments.length;
  262. }
  263. } else {
  264. result = result[0].match(LEVEL_PLAYLIST_REGEX_SLOW);
  265. if (!result) {
  266. logger.warn('No matches on slow regex match for level playlist!');
  267. continue;
  268. }
  269. for (i = 1; i < result.length; i++) {
  270. if (typeof result[i] !== 'undefined') {
  271. break;
  272. }
  273. }
  274.  
  275. // avoid sliced strings https://github.com/video-dev/hls.js/issues/939
  276. const tag = (' ' + result[i]).slice(1);
  277. const value1 = (' ' + result[i + 1]).slice(1);
  278. const value2 = result[i + 2] ? (' ' + result[i + 2]).slice(1) : '';
  279.  
  280. switch (tag) {
  281. case 'PLAYLIST-TYPE':
  282. level.type = value1.toUpperCase();
  283. break;
  284. case 'MEDIA-SEQUENCE':
  285. currentSN = level.startSN = parseInt(value1);
  286. break;
  287. case 'SKIP': {
  288. const skipAttrs = new AttrList(value1);
  289. const skippedSegments = skipAttrs.decimalInteger(
  290. 'SKIPPED-SEGMENTS'
  291. );
  292. if (Number.isFinite(skippedSegments)) {
  293. level.skippedSegments = skippedSegments;
  294. // This will result in fragments[] containing undefined values, which we will fill in with `mergeDetails`
  295. for (let i = skippedSegments; i--; ) {
  296. fragments.unshift(null);
  297. }
  298. currentSN += skippedSegments;
  299. }
  300. const recentlyRemovedDateranges = skipAttrs.enumeratedString(
  301. 'RECENTLY-REMOVED-DATERANGES'
  302. );
  303. if (recentlyRemovedDateranges) {
  304. level.recentlyRemovedDateranges = recentlyRemovedDateranges.split(
  305. '\t'
  306. );
  307. }
  308. break;
  309. }
  310. case 'TARGETDURATION':
  311. level.targetduration = parseFloat(value1);
  312. break;
  313. case 'VERSION':
  314. level.version = parseInt(value1);
  315. break;
  316. case 'EXTM3U':
  317. break;
  318. case 'ENDLIST':
  319. level.live = false;
  320. break;
  321. case '#':
  322. if (value1 || value2) {
  323. frag.tagList.push(value2 ? [value1, value2] : [value1]);
  324. }
  325. break;
  326. case 'DIS':
  327. discontinuityCounter++;
  328. /* falls through */
  329. case 'GAP':
  330. frag.tagList.push([tag]);
  331. break;
  332. case 'BITRATE':
  333. frag.tagList.push([tag, value1]);
  334. break;
  335. case 'DISCONTINUITY-SEQ':
  336. discontinuityCounter = parseInt(value1);
  337. break;
  338. case 'KEY': {
  339. // https://tools.ietf.org/html/rfc8216#section-4.3.2.4
  340. const keyAttrs = new AttrList(value1);
  341. const decryptmethod = keyAttrs.enumeratedString('METHOD');
  342. const decrypturi = keyAttrs.URI;
  343. const decryptiv = keyAttrs.hexadecimalInteger('IV');
  344. const decryptkeyformatversions = keyAttrs.enumeratedString(
  345. 'KEYFORMATVERSIONS'
  346. );
  347. const decryptkeyid = keyAttrs.enumeratedString('KEYID');
  348. // From RFC: This attribute is OPTIONAL; its absence indicates an implicit value of "identity".
  349. const decryptkeyformat =
  350. keyAttrs.enumeratedString('KEYFORMAT') ?? 'identity';
  351.  
  352. const unsupportedKnownKeyformatsInManifest = [
  353. 'com.apple.streamingkeydelivery',
  354. 'com.microsoft.playready',
  355. 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', // widevine (v2)
  356. 'com.widevine', // earlier widevine (v1)
  357. ];
  358.  
  359. if (
  360. unsupportedKnownKeyformatsInManifest.indexOf(decryptkeyformat) >
  361. -1
  362. ) {
  363. logger.warn(
  364. `Keyformat ${decryptkeyformat} is not supported from the manifest`
  365. );
  366. continue;
  367. } else if (decryptkeyformat !== 'identity') {
  368. // We are supposed to skip keys we don't understand.
  369. // As we currently only officially support identity keys
  370. // from the manifest we shouldn't save any other key.
  371. continue;
  372. }
  373.  
  374. // TODO: multiple keys can be defined on a fragment, and we need to support this
  375. // for clients that support both playready and widevine
  376. if (decryptmethod) {
  377. // TODO: need to determine if the level key is actually a relative URL
  378. // if it isn't, then we should instead construct the LevelKey using fromURI.
  379. levelkey = LevelKey.fromURL(baseurl, decrypturi);
  380. if (
  381. decrypturi &&
  382. ['AES-128', 'SAMPLE-AES', 'SAMPLE-AES-CENC'].indexOf(
  383. decryptmethod
  384. ) >= 0
  385. ) {
  386. levelkey.method = decryptmethod;
  387. levelkey.keyFormat = decryptkeyformat;
  388.  
  389. if (decryptkeyid) {
  390. levelkey.keyID = decryptkeyid;
  391. }
  392.  
  393. if (decryptkeyformatversions) {
  394. levelkey.keyFormatVersions = decryptkeyformatversions;
  395. }
  396.  
  397. // Initialization Vector (IV)
  398. levelkey.iv = decryptiv;
  399. }
  400. }
  401. break;
  402. }
  403. case 'START': {
  404. const startAttrs = new AttrList(value1);
  405. const startTimeOffset = startAttrs.decimalFloatingPoint(
  406. 'TIME-OFFSET'
  407. );
  408. // TIME-OFFSET can be 0
  409. if (Number.isFinite(startTimeOffset)) {
  410. level.startTimeOffset = startTimeOffset;
  411. }
  412. break;
  413. }
  414. case 'MAP': {
  415. const mapAttrs = new AttrList(value1);
  416. frag.relurl = mapAttrs.URI;
  417. if (mapAttrs.BYTERANGE) {
  418. frag.setByteRange(mapAttrs.BYTERANGE);
  419. }
  420. frag.level = id;
  421. frag.sn = 'initSegment';
  422. if (levelkey) {
  423. frag.levelkey = levelkey;
  424. }
  425. level.initSegment = frag;
  426. frag = new Fragment(type, baseurl);
  427. frag.rawProgramDateTime = level.initSegment.rawProgramDateTime;
  428. break;
  429. }
  430. case 'SERVER-CONTROL': {
  431. const serverControlAttrs = new AttrList(value1);
  432. level.canBlockReload = serverControlAttrs.bool('CAN-BLOCK-RELOAD');
  433. level.canSkipUntil = serverControlAttrs.optionalFloat(
  434. 'CAN-SKIP-UNTIL',
  435. 0
  436. );
  437. level.canSkipDateRanges =
  438. level.canSkipUntil > 0 &&
  439. serverControlAttrs.bool('CAN-SKIP-DATERANGES');
  440. level.partHoldBack = serverControlAttrs.optionalFloat(
  441. 'PART-HOLD-BACK',
  442. 0
  443. );
  444. level.holdBack = serverControlAttrs.optionalFloat('HOLD-BACK', 0);
  445. break;
  446. }
  447. case 'PART-INF': {
  448. const partInfAttrs = new AttrList(value1);
  449. level.partTarget = partInfAttrs.decimalFloatingPoint('PART-TARGET');
  450. break;
  451. }
  452. case 'PART': {
  453. let partList = level.partList;
  454. if (!partList) {
  455. partList = level.partList = [];
  456. }
  457. const previousFragmentPart =
  458. currentPart > 0 ? partList[partList.length - 1] : undefined;
  459. const index = currentPart++;
  460. const part = new Part(
  461. new AttrList(value1),
  462. frag,
  463. baseurl,
  464. index,
  465. previousFragmentPart
  466. );
  467. partList.push(part);
  468. frag.duration += part.duration;
  469. break;
  470. }
  471. case 'PRELOAD-HINT': {
  472. const preloadHintAttrs = new AttrList(value1);
  473. level.preloadHint = preloadHintAttrs;
  474. break;
  475. }
  476. case 'RENDITION-REPORT': {
  477. const renditionReportAttrs = new AttrList(value1);
  478. level.renditionReports = level.renditionReports || [];
  479. level.renditionReports.push(renditionReportAttrs);
  480. break;
  481. }
  482. default:
  483. logger.warn(`line parsed but not handled: ${result}`);
  484. break;
  485. }
  486. }
  487. }
  488. if (prevFrag && !prevFrag.relurl) {
  489. fragments.pop();
  490. totalduration -= prevFrag.duration;
  491. if (level.partList) {
  492. level.fragmentHint = prevFrag;
  493. }
  494. } else if (level.partList) {
  495. assignProgramDateTime(frag, prevFrag);
  496. frag.cc = discontinuityCounter;
  497. level.fragmentHint = frag;
  498. }
  499. const fragmentLength = fragments.length;
  500. const firstFragment = fragments[0];
  501. const lastFragment = fragments[fragmentLength - 1];
  502. totalduration += level.skippedSegments * level.targetduration;
  503. if (totalduration > 0 && fragmentLength && lastFragment) {
  504. level.averagetargetduration = totalduration / fragmentLength;
  505. const lastSn = lastFragment.sn;
  506. level.endSN = lastSn !== 'initSegment' ? lastSn : 0;
  507. if (firstFragment) {
  508. level.startCC = firstFragment.cc;
  509. if (!level.initSegment) {
  510. // this is a bit lurky but HLS really has no other way to tell us
  511. // if the fragments are TS or MP4, except if we download them :/
  512. // but this is to be able to handle SIDX.
  513. if (
  514. level.fragments.every((frag) =>
  515. MP4_REGEX_SUFFIX.test(frag.relurl as string)
  516. )
  517. ) {
  518. logger.warn(
  519. 'MP4 fragments found but no init segment (probably no MAP, incomplete M3U8), trying to fetch SIDX'
  520. );
  521. frag = new Fragment(type, baseurl);
  522. frag.relurl = lastFragment.relurl;
  523. frag.level = id;
  524. frag.sn = 'initSegment';
  525. level.initSegment = frag;
  526. level.needSidxRanges = true;
  527. }
  528. }
  529. }
  530. } else {
  531. level.endSN = 0;
  532. level.startCC = 0;
  533. }
  534. if (level.fragmentHint) {
  535. totalduration += level.fragmentHint.duration;
  536. }
  537. level.totalduration = totalduration;
  538. level.endCC = discontinuityCounter;
  539.  
  540. /**
  541. * Backfill any missing PDT values
  542. * "If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after
  543. * one or more Media Segment URIs, the client SHOULD extrapolate
  544. * backward from that tag (using EXTINF durations and/or media
  545. * timestamps) to associate dates with those segments."
  546. * We have already extrapolated forward, but all fragments up to the first instance of PDT do not have their PDTs
  547. * computed.
  548. */
  549. if (firstPdtIndex > 0) {
  550. backfillProgramDateTimes(fragments, firstPdtIndex);
  551. }
  552.  
  553. return level;
  554. }
  555. }
  556.  
  557. function setCodecs(codecs: Array<string>, level: LevelParsed) {
  558. ['video', 'audio', 'text'].forEach((type: CodecType) => {
  559. const filtered = codecs.filter((codec) => isCodecType(codec, type));
  560. if (filtered.length) {
  561. const preferred = filtered.filter((codec) => {
  562. return (
  563. codec.lastIndexOf('avc1', 0) === 0 ||
  564. codec.lastIndexOf('mp4a', 0) === 0
  565. );
  566. });
  567. level[`${type}Codec`] = preferred.length > 0 ? preferred[0] : filtered[0];
  568.  
  569. // remove from list
  570. codecs = codecs.filter((codec) => filtered.indexOf(codec) === -1);
  571. }
  572. });
  573.  
  574. level.unknownCodecs = codecs;
  575. }
  576.  
  577. function assignCodec(media, groupItem, codecProperty) {
  578. const codecValue = groupItem[codecProperty];
  579. if (codecValue) {
  580. media[codecProperty] = codecValue;
  581. }
  582. }
  583.  
  584. function backfillProgramDateTimes(
  585. fragments: M3U8ParserFragments,
  586. firstPdtIndex: number
  587. ) {
  588. let fragPrev = fragments[firstPdtIndex] as Fragment;
  589. for (let i = firstPdtIndex; i--; ) {
  590. const frag = fragments[i];
  591. // Exit on delta-playlist skipped segments
  592. if (!frag) {
  593. return;
  594. }
  595. frag.programDateTime =
  596. (fragPrev.programDateTime as number) - frag.duration * 1000;
  597. fragPrev = frag;
  598. }
  599. }
  600.  
  601. function assignProgramDateTime(frag, prevFrag) {
  602. if (frag.rawProgramDateTime) {
  603. frag.programDateTime = Date.parse(frag.rawProgramDateTime);
  604. } else if (prevFrag?.programDateTime) {
  605. frag.programDateTime = prevFrag.endProgramDateTime;
  606. }
  607.  
  608. if (!Number.isFinite(frag.programDateTime)) {
  609. frag.programDateTime = null;
  610. frag.rawProgramDateTime = null;
  611. }
  612. }