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