Source: lib/hls/manifest_text_parser.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.hls.ManifestTextParser');
  7. goog.require('shaka.hls.Attribute');
  8. goog.require('shaka.hls.Playlist');
  9. goog.require('shaka.hls.PlaylistType');
  10. goog.require('shaka.hls.Segment');
  11. goog.require('shaka.hls.Tag');
  12. goog.require('shaka.hls.Utils');
  13. goog.require('shaka.util.Error');
  14. goog.require('shaka.util.StringUtils');
  15. goog.require('shaka.util.TextParser');
  16. /**
  17. * HlS manifest text parser.
  18. */
  19. shaka.hls.ManifestTextParser = class {
  20. /** */
  21. constructor() {
  22. /** @private {number} */
  23. this.globalId_ = 0;
  24. }
  25. /**
  26. * @param {BufferSource} data
  27. * @param {string} absolutePlaylistUri An absolute, final URI after redirects.
  28. * @return {!shaka.hls.Playlist}
  29. */
  30. parsePlaylist(data, absolutePlaylistUri) {
  31. const MEDIA_PLAYLIST_TAGS =
  32. shaka.hls.ManifestTextParser.MEDIA_PLAYLIST_TAGS;
  33. const SEGMENT_TAGS = shaka.hls.ManifestTextParser.SEGMENT_TAGS;
  34. // Get the input as a string. Normalize newlines to \n.
  35. let str = shaka.util.StringUtils.fromUTF8(data);
  36. str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n').trim();
  37. const lines = str.split(/\n+/m);
  38. if (!/^#EXTM3U($|[ \t\n])/m.test(lines[0])) {
  39. throw new shaka.util.Error(
  40. shaka.util.Error.Severity.CRITICAL,
  41. shaka.util.Error.Category.MANIFEST,
  42. shaka.util.Error.Code.HLS_PLAYLIST_HEADER_MISSING);
  43. }
  44. /** shaka.hls.PlaylistType */
  45. let playlistType = shaka.hls.PlaylistType.MASTER;
  46. // First, look for media playlist tags, so that we know what the playlist
  47. // type really is before we start parsing.
  48. // TODO: refactor the for loop for better readability.
  49. // Whether to skip the next element; initialize to true to skip first elem.
  50. let skip = true;
  51. for (const line of lines) {
  52. // Ignore comments.
  53. if (shaka.hls.Utils.isComment(line) || skip) {
  54. skip = false;
  55. continue;
  56. }
  57. const tag = this.parseTag_(line);
  58. // These tags won't actually be used, so don't increment the global
  59. // id.
  60. this.globalId_ -= 1;
  61. if (MEDIA_PLAYLIST_TAGS.includes(tag.name)) {
  62. playlistType = shaka.hls.PlaylistType.MEDIA;
  63. break;
  64. } else if (tag.name == 'EXT-X-STREAM-INF') {
  65. skip = true;
  66. }
  67. }
  68. /** {Array.<shaka.hls.Tag>} */
  69. const tags = [];
  70. // Initialize to "true" to skip the first element.
  71. skip = true;
  72. for (let i = 0; i < lines.length; i++) {
  73. const line = lines[i];
  74. const next = lines[i + 1];
  75. // Skip comments
  76. if (shaka.hls.Utils.isComment(line) || skip) {
  77. skip = false;
  78. continue;
  79. }
  80. const tag = this.parseTag_(line);
  81. if (SEGMENT_TAGS.includes(tag.name)) {
  82. if (playlistType != shaka.hls.PlaylistType.MEDIA) {
  83. // Only media playlists should contain segment tags
  84. throw new shaka.util.Error(
  85. shaka.util.Error.Severity.CRITICAL,
  86. shaka.util.Error.Category.MANIFEST,
  87. shaka.util.Error.Code.HLS_INVALID_PLAYLIST_HIERARCHY);
  88. }
  89. const segmentsData = lines.splice(i, lines.length - i);
  90. const segments = this.parseSegments_(
  91. absolutePlaylistUri, segmentsData, tags);
  92. return new shaka.hls.Playlist(
  93. absolutePlaylistUri, playlistType, tags, segments);
  94. }
  95. tags.push(tag);
  96. // An EXT-X-STREAM-INF tag is followed by a URI of a media playlist.
  97. // Add the URI to the tag object.
  98. if (tag.name == 'EXT-X-STREAM-INF') {
  99. const tagUri = new shaka.hls.Attribute('URI', next);
  100. tag.addAttribute(tagUri);
  101. skip = true;
  102. }
  103. }
  104. return new shaka.hls.Playlist(absolutePlaylistUri, playlistType, tags);
  105. }
  106. /**
  107. * Parses an array of strings into an array of HLS Segment objects.
  108. *
  109. * @param {string} absoluteMediaPlaylistUri
  110. * @param {!Array.<string>} lines
  111. * @param {!Array.<!shaka.hls.Tag>} playlistTags
  112. * @return {!Array.<shaka.hls.Segment>}
  113. * @private
  114. */
  115. parseSegments_(absoluteMediaPlaylistUri, lines, playlistTags) {
  116. /** @type {!Array.<shaka.hls.Segment>} */
  117. const segments = [];
  118. /** @type {!Array.<shaka.hls.Tag>} */
  119. let segmentTags = [];
  120. /** @type {!Array.<shaka.hls.Tag>} */
  121. let partialSegmentTags = [];
  122. // The last parsed EXT-X-MAP tag.
  123. /** @type {?shaka.hls.Tag} */
  124. let currentMapTag = null;
  125. for (const line of lines) {
  126. if (/^(#EXT)/.test(line)) {
  127. const tag = this.parseTag_(line);
  128. if (shaka.hls.ManifestTextParser.MEDIA_PLAYLIST_TAGS.includes(
  129. tag.name)) {
  130. playlistTags.push(tag);
  131. } else {
  132. // Mark the the EXT-X-MAP tag, and add it to the segment tags
  133. // following it later.
  134. if (tag.name == 'EXT-X-MAP') {
  135. currentMapTag = tag;
  136. } else if (tag.name == 'EXT-X-PART') {
  137. partialSegmentTags.push(tag);
  138. } else if (tag.name == 'EXT-X-PRELOAD-HINT') {
  139. if (tag.getAttributeValue('TYPE') == 'PART') {
  140. partialSegmentTags.push(tag);
  141. } else if (tag.getAttributeValue('TYPE') == 'MAP') {
  142. // Rename the Preload Hint tag to be a Map tag.
  143. tag.setName('EXT-X-MAP');
  144. currentMapTag = tag;
  145. }
  146. } else {
  147. segmentTags.push(tag);
  148. }
  149. }
  150. } else if (shaka.hls.Utils.isComment(line)) {
  151. // Skip comments.
  152. } else {
  153. const verbatimSegmentUri = line.trim();
  154. const absoluteSegmentUri = shaka.hls.Utils.constructAbsoluteUri(
  155. absoluteMediaPlaylistUri, verbatimSegmentUri);
  156. // Attach the last parsed EXT-X-MAP tag to the segment.
  157. if (currentMapTag) {
  158. segmentTags.push(currentMapTag);
  159. }
  160. // The URI appears after all of the tags describing the segment.
  161. const segment =
  162. new shaka.hls.Segment(absoluteSegmentUri, segmentTags,
  163. partialSegmentTags);
  164. segments.push(segment);
  165. segmentTags = [];
  166. partialSegmentTags = [];
  167. }
  168. }
  169. // After all the partial segments of a regular segment is published,
  170. // a EXTINF tag and Uri for a regular segment containing the same media
  171. // content will get published at last.
  172. // If no EXTINF tag follows the list of partial segment tags at the end,
  173. // create a segment to wrap the partial segment tags.
  174. if (partialSegmentTags.length) {
  175. if (currentMapTag) {
  176. segmentTags.push(currentMapTag);
  177. }
  178. const segment = new shaka.hls.Segment('', segmentTags,
  179. partialSegmentTags);
  180. segments.push(segment);
  181. }
  182. return segments;
  183. }
  184. /**
  185. * Parses a string into an HLS Tag object while tracking what id to use next.
  186. *
  187. * @param {string} word
  188. * @return {!shaka.hls.Tag}
  189. * @private
  190. */
  191. parseTag_(word) {
  192. return shaka.hls.ManifestTextParser.parseTag(this.globalId_++, word);
  193. }
  194. /**
  195. * Parses a string into an HLS Tag object.
  196. *
  197. * @param {number} id
  198. * @param {string} word
  199. * @return {!shaka.hls.Tag}
  200. */
  201. static parseTag(id, word) {
  202. /* HLS tags start with '#EXT'. A tag can have a set of attributes
  203. (#EXT-<tagname>:<attribute list>) and/or a value (#EXT-<tagname>:<value>).
  204. An attribute's format is 'AttributeName=AttributeValue'.
  205. The parsing logic goes like this:
  206. 1. Everything before ':' is a name (we ignore '#').
  207. 2. Everything after ':' is a list of comma-seprated items,
  208. 2a. The first item might be a value, if it does not contain '='.
  209. 2b. Otherwise, items are attributes.
  210. 3. If there is no ":", it's a simple tag with no attributes and no value.
  211. */
  212. const blocks = word.match(/^#(EXT[^:]*)(?::(.*))?$/);
  213. if (!blocks) {
  214. throw new shaka.util.Error(
  215. shaka.util.Error.Severity.CRITICAL,
  216. shaka.util.Error.Category.MANIFEST,
  217. shaka.util.Error.Code.INVALID_HLS_TAG,
  218. word);
  219. }
  220. const name = blocks[1];
  221. const data = blocks[2];
  222. const attributes = [];
  223. let value;
  224. if (data) {
  225. const parser = new shaka.util.TextParser(data);
  226. let blockAttrs;
  227. // Regex: any number of non-equals-sign characters at the beginning
  228. // terminated by comma or end of line
  229. const valueRegex = /^([^,=]+)(?:,|$)/g;
  230. const blockValue = parser.readRegex(valueRegex);
  231. if (blockValue) {
  232. value = blockValue[1];
  233. }
  234. // Regex:
  235. // 1. Key name ([1])
  236. // 2. Equals sign
  237. // 3. Either:
  238. // a. A quoted string (everything up to the next quote, [2])
  239. // b. An unquoted string
  240. // (everything up to the next comma or end of line, [3])
  241. // 4. Either:
  242. // a. A comma
  243. // b. End of line
  244. const attributeRegex = /([^=]+)=(?:"([^"]*)"|([^",]*))(?:,|$)/g;
  245. while ((blockAttrs = parser.readRegex(attributeRegex))) {
  246. const attrName = blockAttrs[1];
  247. const attrValue = blockAttrs[2] || blockAttrs[3];
  248. const attribute = new shaka.hls.Attribute(attrName, attrValue);
  249. attributes.push(attribute);
  250. parser.skipWhitespace();
  251. }
  252. }
  253. return new shaka.hls.Tag(id, name, attributes, value);
  254. }
  255. };
  256. /**
  257. * HLS tags that only appear on Media Playlists.
  258. * Used to determine a playlist type.
  259. *
  260. * @const {!Array.<string>}
  261. */
  262. shaka.hls.ManifestTextParser.MEDIA_PLAYLIST_TAGS = [
  263. 'EXT-X-TARGETDURATION',
  264. 'EXT-X-MEDIA-SEQUENCE',
  265. 'EXT-X-DISCONTINUITY-SEQUENCE',
  266. 'EXT-X-PLAYLIST-TYPE',
  267. 'EXT-X-I-FRAMES-ONLY',
  268. 'EXT-X-ENDLIST',
  269. 'EXT-X-SERVER-CONTROL',
  270. 'EXT-X-SKIP',
  271. ];
  272. /**
  273. * HLS tags that only appear on Segments in a Media Playlists.
  274. * Used to determine the start of the segments info.
  275. *
  276. * @const {!Array.<string>}
  277. */
  278. shaka.hls.ManifestTextParser.SEGMENT_TAGS = [
  279. 'EXTINF',
  280. 'EXT-X-BYTERANGE',
  281. 'EXT-X-DISCONTINUITY',
  282. 'EXT-X-PROGRAM-DATE-TIME',
  283. 'EXT-X-KEY',
  284. 'EXT-X-DATERANGE',
  285. 'EXT-X-MAP',
  286. ];