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