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