Home Reference Source

src/loader/playlist-loader.ts

  1. /**
  2. * PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models.
  3. *
  4. * Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
  5. *
  6. * Uses loader(s) set in config to do actual internal loading of resource tasks.
  7. *
  8. * @module
  9. *
  10. */
  11.  
  12. import Event from '../events';
  13. import EventHandler from '../event-handler';
  14. import { ErrorTypes, ErrorDetails } from '../errors';
  15. import { logger } from '../utils/logger';
  16. import { Loader, PlaylistContextType, PlaylistLoaderContext, PlaylistLevelType, LoaderCallbacks, LoaderResponse, LoaderStats, LoaderConfiguration } from '../types/loader';
  17. import MP4Demuxer from '../demux/mp4demuxer';
  18. import M3U8Parser from './m3u8-parser';
  19. import { AudioGroup } from '../types/media-playlist';
  20.  
  21. const { performance } = window;
  22.  
  23. /**
  24. * @constructor
  25. */
  26. class PlaylistLoader extends EventHandler {
  27. private loaders: Partial<Record<PlaylistContextType, Loader<PlaylistLoaderContext>>> = {};
  28.  
  29. /**
  30. * @constructs
  31. * @param {Hls} hls
  32. */
  33. constructor (hls) {
  34. super(hls,
  35. Event.MANIFEST_LOADING,
  36. Event.LEVEL_LOADING,
  37. Event.AUDIO_TRACK_LOADING,
  38. Event.SUBTITLE_TRACK_LOADING);
  39. }
  40.  
  41. /**
  42. * @param {PlaylistContextType} type
  43. * @returns {boolean}
  44. */
  45. static canHaveQualityLevels (type: PlaylistContextType): boolean {
  46. return (type !== PlaylistContextType.AUDIO_TRACK &&
  47. type !== PlaylistContextType.SUBTITLE_TRACK);
  48. }
  49.  
  50. /**
  51. * Map context.type to LevelType
  52. * @param {PlaylistLoaderContext} context
  53. * @returns {LevelType}
  54. */
  55. static mapContextToLevelType (context: PlaylistLoaderContext): PlaylistLevelType {
  56. const { type } = context;
  57.  
  58. switch (type) {
  59. case PlaylistContextType.AUDIO_TRACK:
  60. return PlaylistLevelType.AUDIO;
  61. case PlaylistContextType.SUBTITLE_TRACK:
  62. return PlaylistLevelType.SUBTITLE;
  63. default:
  64. return PlaylistLevelType.MAIN;
  65. }
  66. }
  67.  
  68. static getResponseUrl (response: LoaderResponse, context: PlaylistLoaderContext): string {
  69. let url = response.url;
  70. // responseURL not supported on some browsers (it is used to detect URL redirection)
  71. // data-uri mode also not supported (but no need to detect redirection)
  72. if (url === undefined || url.indexOf('data:') === 0) {
  73. // fallback to initial URL
  74. url = context.url;
  75. }
  76. return url;
  77. }
  78.  
  79. /**
  80. * Returns defaults or configured loader-type overloads (pLoader and loader config params)
  81. * Default loader is XHRLoader (see utils)
  82. * @param {PlaylistLoaderContext} context
  83. * @returns {Loader} or other compatible configured overload
  84. */
  85. createInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> {
  86. const config = this.hls.config;
  87. const PLoader = config.pLoader;
  88. const Loader = config.loader;
  89. // TODO(typescript-config): Verify once config is typed that InternalLoader always returns a Loader
  90. const InternalLoader = PLoader || Loader;
  91.  
  92. const loader = new InternalLoader(config);
  93.  
  94. // TODO - Do we really need to assign the instance or if the dep has been lost
  95. context.loader = loader;
  96. this.loaders[context.type] = loader;
  97.  
  98. return loader;
  99. }
  100.  
  101. getInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> | undefined {
  102. return this.loaders[context.type];
  103. }
  104.  
  105. resetInternalLoader (contextType: PlaylistContextType) {
  106. if (this.loaders[contextType]) {
  107. delete this.loaders[contextType];
  108. }
  109. }
  110.  
  111. /**
  112. * Call `destroy` on all internal loader instances mapped (one per context type)
  113. */
  114. destroyInternalLoaders () {
  115. for (let contextType in this.loaders) {
  116. let loader = this.loaders[contextType];
  117. if (loader) {
  118. loader.destroy();
  119. }
  120.  
  121. this.resetInternalLoader(contextType as PlaylistContextType);
  122. }
  123. }
  124.  
  125. destroy () {
  126. this.destroyInternalLoaders();
  127.  
  128. super.destroy();
  129. }
  130.  
  131. onManifestLoading (data: { url: string; }) {
  132. this.load({
  133. url: data.url,
  134. type: PlaylistContextType.MANIFEST,
  135. level: 0,
  136. id: null,
  137. responseType: 'text'
  138. });
  139. }
  140.  
  141. onLevelLoading (data: { url: string; level: number | null; id: number | null; }) {
  142. this.load({
  143. url: data.url,
  144. type: PlaylistContextType.LEVEL,
  145. level: data.level,
  146. id: data.id,
  147. responseType: 'text'
  148. });
  149. }
  150.  
  151. onAudioTrackLoading (data: { url: string; id: number | null; }) {
  152. this.load({
  153. url: data.url,
  154. type: PlaylistContextType.AUDIO_TRACK,
  155. level: null,
  156. id: data.id,
  157. responseType: 'text'
  158. });
  159. }
  160.  
  161. onSubtitleTrackLoading (data: { url: string; id: number | null; }) {
  162. this.load({
  163. url: data.url,
  164. type: PlaylistContextType.SUBTITLE_TRACK,
  165. level: null,
  166. id: data.id,
  167. responseType: 'text'
  168. });
  169. }
  170.  
  171. load (context: PlaylistLoaderContext): boolean {
  172. const config = this.hls.config;
  173.  
  174. logger.debug(`Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);
  175.  
  176. // Check if a loader for this context already exists
  177. let loader = this.getInternalLoader(context);
  178. if (loader) {
  179. const loaderContext = loader.context;
  180. if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap
  181. logger.trace('playlist request ongoing');
  182. return false;
  183. } else {
  184. logger.warn(`aborting previous loader for type: ${context.type}`);
  185. loader.abort();
  186. }
  187. }
  188.  
  189. let maxRetry: number;
  190. let timeout: number;
  191. let retryDelay: number;
  192. let maxRetryDelay: number;
  193.  
  194. // apply different configs for retries depending on
  195. // context (manifest, level, audio/subs playlist)
  196. switch (context.type) {
  197. case PlaylistContextType.MANIFEST:
  198. maxRetry = config.manifestLoadingMaxRetry;
  199. timeout = config.manifestLoadingTimeOut;
  200. retryDelay = config.manifestLoadingRetryDelay;
  201. maxRetryDelay = config.manifestLoadingMaxRetryTimeout;
  202. break;
  203. case PlaylistContextType.LEVEL:
  204. // Disable internal loader retry logic, since we are managing retries in Level Controller
  205. maxRetry = 0;
  206. maxRetryDelay = 0;
  207. retryDelay = 0;
  208. timeout = config.levelLoadingTimeOut;
  209. // TODO Introduce retry settings for audio-track and subtitle-track, it should not use level retry config
  210. break;
  211. default:
  212. maxRetry = config.levelLoadingMaxRetry;
  213. timeout = config.levelLoadingTimeOut;
  214. retryDelay = config.levelLoadingRetryDelay;
  215. maxRetryDelay = config.levelLoadingMaxRetryTimeout;
  216. break;
  217. }
  218.  
  219. loader = this.createInternalLoader(context);
  220.  
  221. const loaderConfig: LoaderConfiguration = {
  222. timeout,
  223. maxRetry,
  224. retryDelay,
  225. maxRetryDelay
  226. };
  227.  
  228. const loaderCallbacks: LoaderCallbacks<PlaylistLoaderContext> = {
  229. onSuccess: this.loadsuccess.bind(this),
  230. onError: this.loaderror.bind(this),
  231. onTimeout: this.loadtimeout.bind(this)
  232. };
  233.  
  234. logger.debug(`Calling internal loader delegate for URL: ${context.url}`);
  235. loader.load(context, loaderConfig, loaderCallbacks);
  236.  
  237. return true;
  238. }
  239.  
  240. loadsuccess (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown = null) {
  241. if (context.isSidxRequest) {
  242. this._handleSidxRequest(response, context);
  243. this._handlePlaylistLoaded(response, stats, context, networkDetails);
  244. return;
  245. }
  246.  
  247. this.resetInternalLoader(context.type);
  248. if (typeof response.data !== 'string') {
  249. throw new Error('expected responseType of "text" for PlaylistLoader');
  250. }
  251.  
  252. const string = response.data;
  253.  
  254. stats.tload = performance.now();
  255. // stats.mtime = new Date(target.getResponseHeader('Last-Modified'));
  256.  
  257. // Validate if it is an M3U8 at all
  258. if (string.indexOf('#EXTM3U') !== 0) {
  259. this._handleManifestParsingError(response, context, 'no EXTM3U delimiter', networkDetails);
  260. return;
  261. }
  262.  
  263. // Check if chunk-list or master. handle empty chunk list case (first EXTINF not signaled, but TARGETDURATION present)
  264. if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) {
  265. this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails);
  266. } else {
  267. this._handleMasterPlaylist(response, stats, context, networkDetails);
  268. }
  269. }
  270.  
  271. loaderror (response: LoaderResponse, context: PlaylistLoaderContext, networkDetails = null) {
  272. this._handleNetworkError(context, networkDetails, false, response);
  273. }
  274.  
  275. loadtimeout (stats: LoaderStats, context: PlaylistLoaderContext, networkDetails = null) {
  276. this._handleNetworkError(context, networkDetails, true);
  277. }
  278.  
  279. // TODO(typescript-config): networkDetails can currently be a XHR or Fetch impl,
  280. // but with custom loaders it could be generic investigate this further when config is typed
  281. _handleMasterPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
  282. const hls = this.hls;
  283. const string = response.data as string;
  284.  
  285. const url = PlaylistLoader.getResponseUrl(response, context);
  286. const { levels, sessionData } = M3U8Parser.parseMasterPlaylist(string, url);
  287. if (!levels.length) {
  288. this._handleManifestParsingError(response, context, 'no level found in manifest', networkDetails);
  289. return;
  290. }
  291.  
  292. // multi level playlist, parse level info
  293. const audioGroups: Array<AudioGroup> = levels.map(level => ({
  294. id: level.attrs.AUDIO,
  295. codec: level.audioCodec
  296. }));
  297.  
  298. const audioTracks = M3U8Parser.parseMasterPlaylistMedia(string, url, 'AUDIO', audioGroups);
  299. const subtitles = M3U8Parser.parseMasterPlaylistMedia(string, url, 'SUBTITLES');
  300. const captions = M3U8Parser.parseMasterPlaylistMedia(string, url, 'CLOSED-CAPTIONS');
  301.  
  302. if (audioTracks.length) {
  303. // check if we have found an audio track embedded in main playlist (audio track without URI attribute)
  304. let embeddedAudioFound = false;
  305. audioTracks.forEach(audioTrack => {
  306. if (!audioTrack.url) {
  307. embeddedAudioFound = true;
  308. }
  309. });
  310.  
  311. // if no embedded audio track defined, but audio codec signaled in quality level,
  312. // we need to signal this main audio track this could happen with playlists with
  313. // alt audio rendition in which quality levels (main)
  314. // contains both audio+video. but with mixed audio track not signaled
  315. if (embeddedAudioFound === false && levels[0].audioCodec && !levels[0].attrs.AUDIO) {
  316. logger.log('audio codec signaled in quality level, but no embedded audio track signaled, create one');
  317. audioTracks.unshift({
  318. type: 'main',
  319. name: 'main',
  320. default: false,
  321. autoselect: false,
  322. forced: false,
  323. id: -1,
  324. attrs: {},
  325. url: ''
  326. });
  327. }
  328. }
  329.  
  330. hls.trigger(Event.MANIFEST_LOADED, {
  331. levels,
  332. audioTracks,
  333. subtitles,
  334. captions,
  335. url,
  336. stats,
  337. networkDetails,
  338. sessionData
  339. });
  340. }
  341.  
  342. _handleTrackOrLevelPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
  343. const hls = this.hls;
  344.  
  345. const { id, level, type } = context;
  346.  
  347. const url = PlaylistLoader.getResponseUrl(response, context);
  348.  
  349. // if the values are null, they will result in the else conditional
  350. const levelUrlId = Number.isFinite(id as number) ? id as number : 0;
  351. const levelId = Number.isFinite(level as number) ? level as number : levelUrlId;
  352.  
  353. const levelType = PlaylistLoader.mapContextToLevelType(context);
  354. const levelDetails = M3U8Parser.parseLevelPlaylist(response.data as string, url, levelId, levelType, levelUrlId);
  355.  
  356. // set stats on level structure
  357. // TODO(jstackhouse): why? mixing concerns, is it just treated as value bag?
  358. (levelDetails as any).tload = stats.tload;
  359.  
  360. if (!levelDetails.fragments.length) {
  361. hls.trigger(Event.ERROR, {
  362. type: ErrorTypes.NETWORK_ERROR,
  363. details: ErrorDetails.LEVEL_EMPTY_ERROR,
  364. fatal: false,
  365. url: url,
  366. reason: 'no fragments found in level',
  367. level: typeof context.level === 'number' ? context.level : undefined
  368. });
  369. return;
  370. }
  371.  
  372. // We have done our first request (Manifest-type) and receive
  373. // not a master playlist but a chunk-list (track/level)
  374. // We fire the manifest-loaded event anyway with the parsed level-details
  375. // by creating a single-level structure for it.
  376. if (type === PlaylistContextType.MANIFEST) {
  377. const singleLevel = {
  378. url,
  379. details: levelDetails
  380. };
  381.  
  382. hls.trigger(Event.MANIFEST_LOADED, {
  383. levels: [singleLevel],
  384. audioTracks: [],
  385. url,
  386. stats,
  387. networkDetails,
  388. sessionData: null
  389. });
  390. }
  391.  
  392. // save parsing time
  393. stats.tparsed = performance.now();
  394.  
  395. // in case we need SIDX ranges
  396. // return early after calling load for
  397. // the SIDX box.
  398. if (levelDetails.needSidxRanges) {
  399. const sidxUrl = levelDetails.initSegment.url;
  400. this.load({
  401. url: sidxUrl,
  402. isSidxRequest: true,
  403. type,
  404. level,
  405. levelDetails,
  406. id,
  407. rangeStart: 0,
  408. rangeEnd: 2048,
  409. responseType: 'arraybuffer'
  410. });
  411. return;
  412. }
  413.  
  414. // extend the context with the new levelDetails property
  415. context.levelDetails = levelDetails;
  416.  
  417. this._handlePlaylistLoaded(response, stats, context, networkDetails);
  418. }
  419.  
  420. _handleSidxRequest (response: LoaderResponse, context: PlaylistLoaderContext) {
  421. if (typeof response.data === 'string') {
  422. throw new Error('sidx request must be made with responseType of array buffer');
  423. }
  424.  
  425. const sidxInfo = MP4Demuxer.parseSegmentIndex(new Uint8Array(response.data));
  426. // if provided fragment does not contain sidx, early return
  427. if (!sidxInfo) {
  428. return;
  429. }
  430. const sidxReferences = sidxInfo.references;
  431. const levelDetails = context.levelDetails;
  432. sidxReferences.forEach((segmentRef, index) => {
  433. const segRefInfo = segmentRef.info;
  434. if (!levelDetails) {
  435. return;
  436. }
  437. const frag = levelDetails.fragments[index];
  438. if (frag.byteRange.length === 0) {
  439. frag.setByteRange(String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start));
  440. }
  441. });
  442.  
  443. if (levelDetails) {
  444. levelDetails.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0');
  445. }
  446. }
  447.  
  448. _handleManifestParsingError (response: LoaderResponse, context: PlaylistLoaderContext, reason: string, networkDetails: unknown) {
  449. this.hls.trigger(Event.ERROR, {
  450. type: ErrorTypes.NETWORK_ERROR,
  451. details: ErrorDetails.MANIFEST_PARSING_ERROR,
  452. fatal: true,
  453. url: response.url,
  454. reason,
  455. networkDetails
  456. });
  457. }
  458.  
  459. _handleNetworkError (context: PlaylistLoaderContext, networkDetails: unknown, timeout: boolean = false, response: LoaderResponse | null = null) {
  460. logger.info(`A network error occured while loading a ${context.type}-type playlist`);
  461.  
  462. let details;
  463. let fatal;
  464.  
  465. const loader = this.getInternalLoader(context);
  466.  
  467. switch (context.type) {
  468. case PlaylistContextType.MANIFEST:
  469. details = (timeout ? ErrorDetails.MANIFEST_LOAD_TIMEOUT : ErrorDetails.MANIFEST_LOAD_ERROR);
  470. fatal = true;
  471. break;
  472. case PlaylistContextType.LEVEL:
  473. details = (timeout ? ErrorDetails.LEVEL_LOAD_TIMEOUT : ErrorDetails.LEVEL_LOAD_ERROR);
  474. fatal = false;
  475. break;
  476. case PlaylistContextType.AUDIO_TRACK:
  477. details = (timeout ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT : ErrorDetails.AUDIO_TRACK_LOAD_ERROR);
  478. fatal = false;
  479. break;
  480. default:
  481. // details = ...?
  482. fatal = false;
  483. }
  484.  
  485. if (loader) {
  486. loader.abort();
  487. this.resetInternalLoader(context.type);
  488. }
  489.  
  490. // TODO(typescript-events): when error events are handled, type this
  491. let errorData: any = {
  492. type: ErrorTypes.NETWORK_ERROR,
  493. details,
  494. fatal,
  495. url: context.url,
  496. loader,
  497. context,
  498. networkDetails
  499. };
  500.  
  501. if (response) {
  502. errorData.response = response;
  503. }
  504.  
  505. this.hls.trigger(Event.ERROR, errorData);
  506. }
  507.  
  508. _handlePlaylistLoaded (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
  509. const { type, level, id, levelDetails } = context;
  510.  
  511. if (!levelDetails || !levelDetails.targetduration) {
  512. this._handleManifestParsingError(response, context, 'invalid target duration', networkDetails);
  513. return;
  514. }
  515.  
  516. const canHaveLevels = PlaylistLoader.canHaveQualityLevels(context.type);
  517. if (canHaveLevels) {
  518. this.hls.trigger(Event.LEVEL_LOADED, {
  519. details: levelDetails,
  520. level: level || 0,
  521. id: id || 0,
  522. stats,
  523. networkDetails
  524. });
  525. } else {
  526. switch (type) {
  527. case PlaylistContextType.AUDIO_TRACK:
  528. this.hls.trigger(Event.AUDIO_TRACK_LOADED, {
  529. details: levelDetails,
  530. id,
  531. stats,
  532. networkDetails
  533. });
  534. break;
  535. case PlaylistContextType.SUBTITLE_TRACK:
  536. this.hls.trigger(Event.SUBTITLE_TRACK_LOADED, {
  537. details: levelDetails,
  538. id,
  539. stats,
  540. networkDetails
  541. });
  542. break;
  543. }
  544. }
  545. }
  546. }
  547.  
  548. export default PlaylistLoader;