From 8afadb477311d56cf10d9056818296069b89e17d Mon Sep 17 00:00:00 2001 From: Sandra Lokshina Date: Tue, 25 Oct 2016 15:53:20 -0700 Subject: [PATCH] Add HLS objects and basic routines to parse manifests into them. Issue #279. Change-Id: I5b6f90b682d77849ce075a2a76a4202c56e5d882 --- build/types/hls | 4 + build/types/manifests | 1 + lib/hls/hls_classes.js | 105 ++++++++++++++++ lib/hls/hls_parser.js | 194 +++++++++++++++++++++++++++++ lib/util/error.js | 14 +++ shaka-player.uncompiled.js | 1 + test/hls/hls_parser_unit.js | 241 ++++++++++++++++++++++++++++++++++++ 7 files changed, 560 insertions(+) create mode 100644 build/types/hls create mode 100644 lib/hls/hls_classes.js create mode 100644 lib/hls/hls_parser.js create mode 100644 test/hls/hls_parser_unit.js diff --git a/build/types/hls b/build/types/hls new file mode 100644 index 0000000000..c8e3eacb9a --- /dev/null +++ b/build/types/hls @@ -0,0 +1,4 @@ +# The HLS manifest parser plugin. + ++../../lib/hls/hls_classes.js ++../../lib/hls/hls_parser.js diff --git a/build/types/manifests b/build/types/manifests index d703b70e2e..29426067ac 100644 --- a/build/types/manifests +++ b/build/types/manifests @@ -1,4 +1,5 @@ # All standard manifest parser plugins. +@dash ++@hls +@offline diff --git a/lib/hls/hls_classes.js b/lib/hls/hls_classes.js new file mode 100644 index 0000000000..cba14a7d19 --- /dev/null +++ b/lib/hls/hls_classes.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +goog.provide('shaka.hls.Attribute'); +goog.provide('shaka.hls.Playlist'); +goog.provide('shaka.hls.PlaylistType'); +goog.provide('shaka.hls.Tag'); + + + +/** + * Creates an HLS playlist object. + * + * @param {!shaka.hls.PlaylistType} type + * @param {Array.} tags + * @param {Array.=} opt_segmentsData + * + * @constructor + * @struct + */ +shaka.hls.Playlist = function(type, tags, opt_segmentsData) { + /** @const {shaka.hls.PlaylistType} */ + this.type = type; + + /** @const {Array.} */ + this.tags = tags; + + /** @const {Array.} */ + this.segmentsData = opt_segmentsData || null; +}; + + +/** + * @enum {number} + */ +shaka.hls.PlaylistType = { + MASTER: 0, + MEDIA: 1 +}; + + + +/** + * Creates an HLS tag object. + * + * @param {!string} name + * @param {!Array.} attributes + * @param {?string=} opt_value + * + * @constructor + * @struct + */ +shaka.hls.Tag = function(name, attributes, opt_value) { + /** @const {!string} */ + this.name = name; + + /** @const {!Array.} */ + this.attributes = attributes; + + /** @const {?string} */ + this.value = opt_value || null; +}; + + + +/** + * Creates an HLS attribute object. + * + * @param {!string} name + * @param {!string} value + * + * @constructor + * @struct + */ +shaka.hls.Attribute = function(name, value) { + /** @const {!string} */ + this.name = name; + + /** @const {!string} */ + this.value = value; +}; + + +/** + * Adds an attribute to an HLS Tag. + * + * @param {!shaka.hls.Attribute} attribute + */ +shaka.hls.Tag.prototype.addAttribute = function(attribute) { + this.attributes.push(attribute); +}; diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js new file mode 100644 index 0000000000..353289678f --- /dev/null +++ b/lib/hls/hls_parser.js @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +goog.provide('shaka.hls.HlsParser'); + +goog.require('shaka.hls.Attribute'); +goog.require('shaka.hls.Playlist'); +goog.require('shaka.hls.PlaylistType'); +goog.require('shaka.hls.Tag'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.StringUtils'); +goog.require('shaka.util.TextParser'); + + + +/** + * Creates a new HLS parser. + * + * @struct + * @constructor + */ +// TODO: make it implement {shakaExtern.ManifestParser} +shaka.hls.HlsParser = function() {}; + + +/** + * @param {ArrayBuffer} data + * @return {!shaka.hls.Playlist} + * @throws {shaka.util.Error} + */ +// TODO: make private +shaka.hls.HlsParser.prototype.parsePlaylist = function(data) { + // Get the input as a string. Normalize newlines to \n. + var str = shaka.util.StringUtils.fromUTF8(data); + str = str.replace(/\r\n|\r(?=[^\n]|$)/gm, '\n'); + + var lines = str.split(/\n+/m); + + if (!/^#EXTM3U($|[ \t\n])/m.test(lines[0])) { + throw new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_PLAYLIST_HEADER_MISSING); + } + + var HlsParser = shaka.hls.HlsParser; + + /** shaka.hls.PlaylistType */ + var playlistType = shaka.hls.PlaylistType.MASTER; + + /** {Array.} */ + var tags = []; + + var i = 1; + while (i < lines.length) { + // Skip comments + if (/^#(?!EXT)/m.test(lines[i])) { + i += 1; + continue; + } + + var tag = this.parseTag_(lines[i]); + + + if (HlsParser.MEDIA_PLAYLIST_TAGS.indexOf(tag.name) >= 0) { + playlistType = shaka.hls.PlaylistType.MEDIA; + } else if (HlsParser.SEGMENT_TAGS.indexOf(tag.name) >= 0) { + if (playlistType != shaka.hls.PlaylistType.MEDIA) { + // Only media playlist should contain segment tags + throw new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.HLS_INVALID_PLAYLIST_HIERARCHY); + } + + var segmentsData = lines.splice(i, lines.length - i); + return new shaka.hls.Playlist(playlistType, tags, segmentsData); + } + + tags.push(tag); + i += 1; + + // EXT-X-STREAM-INF tag is followed by a uri of a media playlist. + // Add uri to the tag object. + if (tag.name == 'EXT-X-STREAM-INF') { + var uri = new shaka.hls.Attribute('URI', lines[i]); + tag.addAttribute(uri); + i += 1; + } + } + + return new shaka.hls.Playlist(playlistType, tags); +}; + + +/** + * Parses a string into an HLS Tag object. + * + * @param {!string} word + * @return {!shaka.hls.Tag} + * @throws {shaka.util.Error} + * @private + */ +shaka.hls.HlsParser.prototype.parseTag_ = function(word) { + /* HLS tags start with '#EXT'. A tag can have a set of attributes + (#EXT-:) or a value (#EXT-:). + Attributes' format is 'AttributeName=AttributeValue'. + The parsing logic goes like this: + 1) Everything before ':' is a name (we ignore '#'). + 2) Everything after should be parsed as attributes if it contains '='. + 3) Otherwise, this is a value. + 4) If there is no ":", it's a simple tag with no attributes and no value */ + + var blocks = word.match(/^#(EXT[^:]*)(?::(.*))?$/); + if (!blocks) { + throw new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.INVALID_HLS_TAG); + } + var name = blocks[1]; + var data = blocks[2]; + var attributes = []; + + if (data && data.indexOf('=') >= 0) { + var parser = new shaka.util.TextParser(data); + var blockAttrs; + + // Regex: + // 1. Key name ([1]) + // 2. Equals sign + // 3. Either: + // a. A quoted string (everything up to the next quote, [2]) + // b. An unquoted string (everything up to the next comma or end of line, + // [3]) + // 4. Either: + // a. A comma + // b. End of line + var regex = /([^=]+)=(?:"([^"]*)"|([^",]*))(?:,|$)/g; + while (blockAttrs = parser.readRegex(regex)) { + var attrName = blockAttrs[1]; + var attrValue = blockAttrs[2] || blockAttrs[3]; + var attribute = new shaka.hls.Attribute(attrName, attrValue); + attributes.push(attribute); + } + } else if (data) { + return new shaka.hls.Tag(name, attributes, data); + } + + return new shaka.hls.Tag(name, attributes); +}; + + +/** + * HLS tags that only appear on Media Playlists. + * Used to determine a playlist type. + * + * @const {!Array} + */ +shaka.hls.HlsParser.MEDIA_PLAYLIST_TAGS = [ + 'EXT-X-TARGETDURATION', + 'EXT-X-MEDIA-SEQUENCE', + 'EXT-X-DISCONTINUITY-SEQUENCE', + 'EXT-X-PLAYLIST-TYPE', + 'EXT-X-I-FRAMES-ONLY' +]; + + +/** + * HLS tags that only appear on Segments in a Media Playlists. + * Used to determine the start of the segments info. + * + * @const {!Array} + */ +shaka.hls.HlsParser.SEGMENT_TAGS = [ + 'EXTINF', + 'EXT-X-BYTERANGE', + 'EXT-X-DISCONTINUITY', + 'EXT-X-KEY', + 'EXT-X-MAP', + 'EXT-X-PROGRAM-DATE-TIME', + 'EXT-X-DATERANGE' +]; diff --git a/lib/util/error.js b/lib/util/error.js index a8298ab55b..4425530b42 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -417,6 +417,20 @@ shaka.util.Error.Code = { */ 'NO_PERIODS': 4014, + /** + * HLS playlist doesn't start with a mandory #EXTM3U tag. + */ + 'HLS_PLAYLIST_HEADER_MISSING': 4015, + + /** + * HLS tag has an invalid name that doesn't start with '#EXT' + */ + 'INVALID_HLS_TAG': 4016, + + /** + * HLS playlist has both Master and Media/Segment tags. + */ + 'HLS_INVALID_PLAYLIST_HIERARCHY': 4017, // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index e733664891..825e33481d 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -25,6 +25,7 @@ goog.require('shaka.abr.SimpleAbrManager'); goog.require('shaka.cast.CastProxy'); goog.require('shaka.cast.CastReceiver'); goog.require('shaka.dash.DashParser'); +goog.require('shaka.hls.HlsParser'); goog.require('shaka.log'); goog.require('shaka.media.InitSegmentReference'); goog.require('shaka.media.ManifestParser'); diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js new file mode 100644 index 0000000000..42a54f9526 --- /dev/null +++ b/test/hls/hls_parser_unit.js @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +describe('HlsParser', function() { + var parser; + + beforeEach(function() { + parser = new shaka.hls.HlsParser(); + }); + + describe('parsePlaylist', function() { + it('rejects invalid playlists', function() { + verifyError('invalid playlist', + shaka.util.Error.Code.HLS_PLAYLIST_HEADER_MISSING); + + // This Master playlist is invalid cause it contains a segment tag. + // All segmnent information should be in a Media playlist. + verifyError('#EXTM3U\n' + + '#EXT-X-MEDIA:TYPE=AUDIO\n' + + '#EXTINF:6.00600', + shaka.util.Error.Code.HLS_INVALID_PLAYLIST_HIERARCHY); + }); + + it('parses a Media Playlist', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MEDIA, + tags: [ + new shaka.hls.Tag('EXT-X-TARGETDURATION', [], '6') + ], + segmentsData: ['#EXTINF:6.00600,', 'main.mp4'] + }, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:6\n' + + '#EXTINF:6.00600,\n' + + 'main.mp4'); + }); + + it('parses a Master Playlist', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MEDIA, + tags: [ + new shaka.hls.Tag('EXT-X-TARGETDURATION', [], '6'), + new shaka.hls.Tag('EXT-X-STREAM-INF', + [ + new shaka.hls.Attribute('BANDWIDTH', '2165224'), + new shaka.hls.Attribute('URI', 'prog_index.m3u8') + ]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-TARGETDURATION:6\n' + + '#EXT-X-STREAM-INF:BANDWIDTH=2165224\n' + + 'prog_index.m3u8'); + }); + + it('ignores comments', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MEDIA, + tags: [ + new shaka.hls.Tag('EXT-X-TARGETDURATION', [], '6') + ], + segmentsData: ['#EXTINF:6.00600,', 'main.mp4'] + }, + '#EXTM3U\n' + + '#Comment\n' + + '#EXT-X-TARGETDURATION:6\n' + + '#EXTINF:6.00600,\n' + + 'main.mp4'); + }); + + /** + * @param {!string} string + * @param {shaka.util.Error.Code} code + */ + function verifyError(string, code) { + var data = shaka.util.StringUtils.toUTF8(string); + var error = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + code); + try { + parser.parsePlaylist(data); + fail('Invalid HLS playlist should not be supported!'); + } catch (e) { + shaka.test.Util.expectToEqualError(e, error); + } + } + }); + + describe('parseTag', function() { + it('parses tags with no attributes', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-INDEPENDENT-SEGMENTS', []) + ] + }, + '#EXTM3U\n' + + '#EXT-X-INDEPENDENT-SEGMENTS'); + + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MEDIA, + tags: [ + new shaka.hls.Tag('EXT-X-PLAYLIST-TYPE', [], 'VOD') + ] + }, + '#EXTM3U\n' + + '#EXT-X-PLAYLIST-TYPE:VOD'); + + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MEDIA, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA-SEQUENCE', [], '1') + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1'); + }); + + it('parses tags with attributes', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA', + [new shaka.hls.Attribute('TYPE', 'CLOSED-CAPTIONS')]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS'); + + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA', + [ + new shaka.hls.Attribute('URI', 'main.mp4'), + new shaka.hls.Attribute('BYTERANGE', '720@0') + ]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA:URI="main.mp4",BYTERANGE="720@0"'); + }); + + it('parses tags with commas in attribute values', function() { + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA', + [ + new shaka.hls.Attribute('CODECS', 'avc1.64002a,mp4a.40.2') + ]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA:CODECS="avc1.64002a,mp4a.40.2"'); + + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA', + [ + new shaka.hls.Attribute('CODECS', + 'avc1.64002a,mp4a.40.2,avc2.64000') + ]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA:CODECS="avc1.64002a,mp4a.40.2,avc2.64000"'); + + verifyPlaylist( + { + type: shaka.hls.PlaylistType.MASTER, + tags: [ + new shaka.hls.Tag('EXT-X-MEDIA', + [ + new shaka.hls.Attribute('CODECS', + 'avc1.64002a,mp4a.40.2'), + new shaka.hls.Attribute('AUDIO', 'a1,a2') + ]) + ] + }, + '#EXTM3U\n' + + '#EXT-X-MEDIA:CODECS="avc1.64002a,mp4a.40.2",AUDIO="a1,a2"'); + }); + + it('rejects invalid tags', function() { + var error = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.INVALID_HLS_TAG); + var text = shaka.util.StringUtils.toUTF8('#EXTM3U\ninvalid tag'); + try { + parser.parsePlaylist(text); + fail('Invalid HLS tags should not be supported!'); + } catch (e) { + shaka.test.Util.expectToEqualError(e, error); + } + }); + }); + + + /** + * @param {Object} expected + * @param {!string} string + */ + function verifyPlaylist(expected, string) { + var data = shaka.util.StringUtils.toUTF8(string); + var actual = parser.parsePlaylist(data); + + expect(actual).toBeTruthy(); + expect(actual.type).toEqual(expected.type); + expect(actual.tags).toEqual(expected.tags); + + if (expected.segmentsData) + expect(actual.segmentsData).toEqual(expected.segmentsData); + } +});