diff --git a/AUTHORS b/AUTHORS index df40777dd5..064957e8f2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Google Inc. <*@google.com> Edgeware AB <*@edgeware.tv> Gil Gonen Giuseppe Samela +Hotstar <*@hotstar.com> Itay Kinnrot Jason Palmer Jesper Haug Karsrud diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 6a33e3d9c0..ef700389d2 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -25,6 +25,7 @@ Aaron Vaage Alvaro Velad Galvan Andy Hochhaus +Anurag Kalia Benjamin Wallberg Boris Cupac Bryan Huh diff --git a/externs/shaka/manifest_parser.js b/externs/shaka/manifest_parser.js index 211072f3d7..47483826ad 100644 --- a/externs/shaka/manifest_parser.js +++ b/externs/shaka/manifest_parser.js @@ -59,6 +59,7 @@ shaka.extern.ManifestParser = function() {}; * filterNewPeriod: function(shaka.extern.Period), * filterAllPeriods: function(!Array.), * onTimelineRegionAdded: function(shaka.extern.TimelineRegionInfo), + * onSegmentParsed: function(string, number, *), * onEvent: function(!Event), * onError: function(!shaka.util.Error) * }} @@ -77,6 +78,8 @@ shaka.extern.ManifestParser = function() {}; * Should be called on all Periods so that they can be filtered. * @property {function(shaka.extern.TimelineRegionInfo)} onTimelineRegionAdded * Should be called when a new timeline region is added. + * @property {function(string, number, *)} onSegmentParsed + * Should be called with segment metadata when a new segment is parsed. * @property {function(!Event)} onEvent * Should be called to raise events. * @property {function(!shaka.util.Error)} onError diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 3d8318f127..5efa5fbdbb 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -287,7 +287,9 @@ shaka.hls.HlsParser = class { playlist, startPosition, stream.mimeType, - stream.codecs); + stream.codecs, + stream.id, + streamInfo.segmentIndex.getAll()); streamInfo.segmentIndex.replace(segments); @@ -1167,8 +1169,11 @@ shaka.hls.HlsParser = class { const startPosition = mediaSequenceTag ? Number(mediaSequenceTag.value) : 0; + const nextStreamId = this.globalId_++; + const segments = await this.createSegments_( - verbatimMediaPlaylistUri, playlist, startPosition, mimeType, codecs); + verbatimMediaPlaylistUri, playlist, startPosition, mimeType, codecs, + nextStreamId, []); const minTimestamp = segments[0].startTime; const lastEndTime = segments[segments.length - 1].endTime; @@ -1186,7 +1191,7 @@ shaka.hls.HlsParser = class { /** @type {shaka.extern.Stream} */ const stream = { - id: this.globalId_++, + id: nextStreamId, originalId: name, createSegmentIndex: () => Promise.resolve(), findSegmentPosition: (i) => segmentIndex.find(i), @@ -1435,16 +1440,34 @@ shaka.hls.HlsParser = class { * @param {number} startPosition * @param {string} mimeType * @param {string} codecs + * @param {number} streamId + * @param {Array.} existingSegmentRefs * @return {!Promise>} * @private */ async createSegments_( - verbatimMediaPlaylistUri, playlist, startPosition, mimeType, codecs) { + verbatimMediaPlaylistUri, playlist, startPosition, mimeType, codecs, + streamId, existingSegmentRefs) { /** @type {Array.} */ const hlsSegments = playlist.segments; /** @type {!Array.} */ const references = []; - + /** + * Number of segment references in both the last and the current playlist. + * @type {number} + */ + let reappearingRefsCount = 0; + + if (existingSegmentRefs.length > 0) { + const earlierStartPosition = existingSegmentRefs[0].getPosition(); + /** + * Number of segment references in the last but not the current playlist + * (in HLS, segments are dropped only from the beginning of the playlist). + * @type {number} + */ + const droppedRefsCount = startPosition - earlierStartPosition; + reappearingRefsCount = existingSegmentRefs.length - droppedRefsCount; + } goog.asserts.assert(hlsSegments.length, 'Playlist should have segments!'); // We may need to look at the media itself to determine a segment start // time. @@ -1463,12 +1486,23 @@ shaka.hls.HlsParser = class { initSegmentRef, firstSegmentRef, mimeType, codecs); shaka.log.debug('First segment', firstSegmentUri.split('/').pop(), 'starts at', firstStartTime); + + // Append brand-new segments for (let i = 0; i < hlsSegments.length; ++i) { const hlsSegment = hlsSegments[i]; const previousReference = references[references.length - 1]; const startTime = (i == 0) ? firstStartTime : previousReference.endTime; const position = startPosition + i; + if (i >= reappearingRefsCount) { + // Segment references which appeared only in the current playlist: + // Fire 'segmentparsed' event for them. + this.playerInterface_.onSegmentParsed( + 'application/vnd.apple.mpegurl', + streamId, + hlsSegment); + } + const reference = this.createSegmentReference_( playlist, previousReference, diff --git a/lib/media/segment_index.js b/lib/media/segment_index.js index abebfb8815..3e6f23f860 100644 --- a/lib/media/segment_index.js +++ b/lib/media/segment_index.js @@ -107,6 +107,16 @@ shaka.media.SegmentIndex = class { return this.references_[index]; } + /** + * Get all the SegmentReferences + * + * @return {Array.} existing segment references + * @export + */ + getAll() { + return this.references_; + } + /** * Offset all segment references by a fixed amount. diff --git a/lib/offline/storage.js b/lib/offline/storage.js index 2b0742c49f..74d56dc322 100644 --- a/lib/offline/storage.js +++ b/lib/offline/storage.js @@ -797,6 +797,7 @@ shaka.offline.Storage = class { filterNewPeriod: () => {}, onTimelineRegionAdded: () => {}, + onSegmentParsed: () => {}, onEvent: () => {}, // Used to capture an error from the manifest parser. We will check the diff --git a/lib/player.js b/lib/player.js index 04dd24fa72..6e04878538 100644 --- a/lib/player.js +++ b/lib/player.js @@ -266,6 +266,21 @@ goog.require('shaka.util.StreamUtils'); * @exportDoc */ +/** + * @event shaka.Player.SegmentParsedEvent + * @description Optionally fired from inside a manifest parser plugin if a newly + * parsed segment has some extra metadata. + * @property {string} type + * 'segmentparsed' + * @property {string} mimeType + * Mime type of the manifest parser. + * @property {number} streamId + * Id of the stream that segment belonged to. + * @property {*} segmentData + * Its format depends on the manifest parser. + * @exportDoc + */ + /** * @event shaka.Player.StreamingEvent @@ -1451,6 +1466,11 @@ shaka.Player = class extends shaka.util.FakeEventTarget { // manifest). onTimelineRegionAdded: (region) => this.regionTimeline_.addRegion(region), + // Called to signal new segment metadata upon parsing in the manifest + // segment metadata can vary depending on mimeType and streamId + onSegmentParsed: (mimeType, streamId, segment) => this.onSegmentParsed_( + mimeType, streamId, segment), + onEvent: (event) => this.dispatchEvent(event), onError: (error) => this.onError_(error), }; @@ -4249,6 +4269,26 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } } + /** + * Callback from player + * + * @param {string} mimeType Mime type of the manifest parser. + * @param {number} streamId Id of the stream that segment belonged to. + * @param {*} segmentData Its format depends on the manifest parser. + * @private + */ + onSegmentParsed_(mimeType, streamId, segmentData) { + const eventData = { + mimeType, + streamId, + segmentData, + }; + + this.dispatchEvent( + new shaka.util.FakeEvent('segmentparsed', eventData) + ); + } + /** * Callback from AbrManager. * diff --git a/test/dash/dash_parser_content_protection_unit.js b/test/dash/dash_parser_content_protection_unit.js index 7e61199317..b550a36dcc 100644 --- a/test/dash/dash_parser_content_protection_unit.js +++ b/test/dash/dash_parser_content_protection_unit.js @@ -47,6 +47,7 @@ describe('DashParser ContentProtection', () => { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; diff --git a/test/dash/dash_parser_live_unit.js b/test/dash/dash_parser_live_unit.js index adc97dcc02..7087352512 100644 --- a/test/dash/dash_parser_live_unit.js +++ b/test/dash/dash_parser_live_unit.js @@ -47,6 +47,7 @@ describe('DashParser Live', () => { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; diff --git a/test/dash/dash_parser_manifest_unit.js b/test/dash/dash_parser_manifest_unit.js index 010bd28e85..68bc93ee4b 100644 --- a/test/dash/dash_parser_manifest_unit.js +++ b/test/dash/dash_parser_manifest_unit.js @@ -38,6 +38,7 @@ describe('DashParser Manifest', () => { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: shaka.test.Util.spyFunc(onEventSpy), onError: fail, }; diff --git a/test/dash/dash_parser_segment_base_unit.js b/test/dash/dash_parser_segment_base_unit.js index 126bcfc3b7..d798b4ac51 100644 --- a/test/dash/dash_parser_segment_base_unit.js +++ b/test/dash/dash_parser_segment_base_unit.js @@ -42,6 +42,7 @@ describe('DashParser SegmentBase', () => { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; diff --git a/test/dash/dash_parser_segment_template_unit.js b/test/dash/dash_parser_segment_template_unit.js index ba436040e5..822d026e1a 100644 --- a/test/dash/dash_parser_segment_template_unit.js +++ b/test/dash/dash_parser_segment_template_unit.js @@ -36,6 +36,7 @@ describe('DashParser SegmentTemplate', () => { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; diff --git a/test/hls/hls_live_unit.js b/test/hls/hls_live_unit.js index 27e56a3c3a..314538b5d0 100644 --- a/test/hls/hls_live_unit.js +++ b/test/hls/hls_live_unit.js @@ -124,12 +124,22 @@ describe('HlsParser live', () => { onError: fail, onEvent: fail, onTimelineRegionAdded: fail, + onSegmentParsed: fail, }; parser = new shaka.hls.HlsParser(); parser.configure(config); }); + /** @type {!jasmine.Spy} */ + let onSegmentParsedSpy; + + beforeEach(() => { + onSegmentParsedSpy = jasmine.createSpy('onSegmentParsed'); + playerInterface.onSegmentParsed = + shaka.test.Util.spyFunc(onSegmentParsedSpy); + }); + afterEach(() => { // HLS parser stop is synchronous. parser.stop(); @@ -555,6 +565,67 @@ describe('HlsParser live', () => { master, media, [ref1], mediaWithAdditionalSegment, [ref1, ref2]); }); + it('triggers segment parsed callbacks as segments appear', () => { + const mediaWithAdditionalSegment = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:EVENT\n', + '#EXT-X-TARGETDURATION:5\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:2,\n', + 'main.mp4\n', + '#EXT-X-CUE:DURATION="201.467",ID="0",TYPE="SpliceOut",', + 'TIME="414.171"\n', + '#EXTINF:2,\n', + 'main2.mp4\n', + ].join(''); + + fakeNetEngine + .setResponseText('test:/master', master) + .setResponseText('test:/video', media) + .setResponseValue('test:/init.mp4', initSegmentData) + .setResponseValue('test:/main.mp4', segmentData); + + const spy = jasmine.createSpy('start'); + parser.start('test:/master', playerInterface) + .then(Util.spyFunc(spy), fail); + PromiseMock.flush(); + + expect(onSegmentParsedSpy).toHaveBeenCalledTimes(1); + expect(onSegmentParsedSpy).toHaveBeenCalledWith( + 'application/vnd.apple.mpegurl', + 1, + new shaka.hls.Segment( + 'test:/main.mp4', + [ + new shaka.hls.Tag(/* id */ 1, 'EXTINF', [], '2'), + ]) + ); + + // Replace the entries with the updated values. + fakeNetEngine + .setResponseText('test:/video', mediaWithAdditionalSegment); + + delayForUpdatePeriod(); + + expect(onSegmentParsedSpy).toHaveBeenCalledTimes(2); + expect(onSegmentParsedSpy).toHaveBeenCalledWith( + 'application/vnd.apple.mpegurl', + 1, + new shaka.hls.Segment( + 'test:/main2.mp4', + [ + new shaka.hls.Tag(/* id */ 1, 'EXTINF', [], '2'), + new shaka.hls.Tag(/* id */ 2, 'EXT-X-CUE', [ + new shaka.hls.Attribute('DURATION', '201.467'), + new shaka.hls.Attribute('ID', '0'), + new shaka.hls.Attribute('TYPE', 'SpliceOut'), + new shaka.hls.Attribute('TIME', '414.171'), + ]), + ] + ) + ); + }); + it('evicts removed segments', () => { const ref1 = ManifestParser.makeReference('test:/main.mp4', 0, 2, 4); diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 09dbe9ac7c..469f8d44de 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -91,12 +91,23 @@ describe('HlsParser', () => { onError: fail, onEvent: fail, onTimelineRegionAdded: fail, + onSegmentParsed: fail, }; parser = new shaka.hls.HlsParser(); parser.configure(config); }); + /** @type {!jasmine.Spy} */ + let onSegmentParsedSpy; + + beforeEach(() => { + onSegmentParsedSpy = jasmine.createSpy('onSegmentParsed'); + playerInterface.onSegmentParsed = + shaka.test.Util.spyFunc(onSegmentParsedSpy); + }); + + /** * @param {string} master * @param {string} media diff --git a/test/test/util/dash_parser_util.js b/test/test/util/dash_parser_util.js index 5eca238cb7..0843a64cb3 100644 --- a/test/test/util/dash_parser_util.js +++ b/test/test/util/dash_parser_util.js @@ -50,6 +50,7 @@ shaka.test.Dash = class { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; @@ -77,6 +78,7 @@ shaka.test.Dash = class { filterNewPeriod: () => {}, filterAllPeriods: () => {}, onTimelineRegionAdded: fail, // Should not have any EventStream elements. + onSegmentParsed: fail, onEvent: fail, onError: fail, }; diff --git a/test/test/util/util.js b/test/test/util/util.js index 9e5f312a12..f16726f01f 100644 --- a/test/test/util/util.js +++ b/test/test/util/util.js @@ -226,6 +226,40 @@ shaka.test.Util = class { return undefined; } + /** + * Custom comparer for HLS segments. + * @param {*} first + * @param {*} second + * @return {boolean|undefined} + */ + + static compareHlsSegments(first, second) { + const isHlsSegment = first instanceof shaka.hls.Segment && + second instanceof shaka.hls.Segment; + + if (isHlsSegment) { + return first.absoluteUri === second.absoluteUri && + compareTagsArray(first.tags, second.tags); + } + + return undefined; + + + function compareTagsArray(tags1, tags2) { + if (tags1 instanceof Array && tags2 instanceof Array) { + try { + const tagsAsString = (tagsArray) => tagsArray.map((tags) => + tags.toString()).sort().join(','); + + return tagsAsString(tags1) === tagsAsString(tags2); + } catch (e) { + return false; + } + } + return false; + } + } + /** * Fetches the resource at the given URI. * @@ -475,5 +509,6 @@ shaka.test.Util.customMatchers_ = { beforeEach(() => { jasmine.addCustomEqualityTester(shaka.test.Util.compareReferences); + jasmine.addCustomEqualityTester(shaka.test.Util.compareHlsSegments); jasmine.addMatchers(shaka.test.Util.customMatchers_); });