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