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