From 48dd20562c2226f61cc753a922629e44c1866f6d Mon Sep 17 00:00:00 2001 From: theodab Date: Wed, 30 Mar 2022 17:27:50 -0700 Subject: [PATCH] fix(hls): Support playing media playlists directly (#4080) Closes #3536 --- demo/common/asset.js | 18 ++- demo/common/assets.js | 9 ++ demo/common/message_ids.js | 2 + demo/custom.js | 39 +++++++ demo/locales/en.json | 2 + demo/locales/source.json | 8 ++ externs/shaka/player.js | 12 +- lib/hls/hls_parser.js | 185 ++++++++++++++++++++++--------- lib/util/error.js | 8 +- lib/util/mime_utils.js | 2 + lib/util/player_configuration.js | 7 +- test/demo/demo_unit.js | 3 +- test/hls/hls_parser_unit.js | 50 +++++++++ 13 files changed, 279 insertions(+), 66 deletions(-) diff --git a/demo/common/asset.js b/demo/common/asset.js index 7d39f25be3..8205db7918 100644 --- a/demo/common/asset.js +++ b/demo/common/asset.js @@ -77,6 +77,8 @@ const ShakaDemoAssetInfo = class { this.imaContentSrcId = null; /** @type {?string} */ this.mimeType = null; + /** @type {?string} */ + this.mediaPlaylistFullMimeType = null; // Offline storage values. @@ -161,6 +163,15 @@ const ShakaDemoAssetInfo = class { return this.drm.length == 1 && this.drm[0] == shakaAssets.KeySystem.CLEAR; } + /** + * @param {string} mediaPlaylistFullMimeType + * @return {!ShakaDemoAssetInfo} + */ + setMediaPlaylistFullMimeType(mediaPlaylistFullMimeType) { + this.mediaPlaylistFullMimeType = mediaPlaylistFullMimeType; + return this; + } + /** * @param {!Object} extraConfig * @return {!ShakaDemoAssetInfo} @@ -368,7 +379,7 @@ const ShakaDemoAssetInfo = class { */ getConfiguration() { const config = /** @type {shaka.extern.PlayerConfiguration} */( - {drm: {advanced: {}}, manifest: {dash: {}}}); + {drm: {advanced: {}}, manifest: {dash: {}, hls: {}}}); if (this.extraConfig) { for (const key in this.extraConfig) { @@ -376,6 +387,11 @@ const ShakaDemoAssetInfo = class { } } + if (this.mediaPlaylistFullMimeType) { + config.manifest.hls.mediaPlaylistFullMimeType = + this.mediaPlaylistFullMimeType; + } + if (this.licenseServers.size) { config.drm.servers = config.drm.servers || {}; this.licenseServers.forEach((value, key) => { diff --git a/demo/common/assets.js b/demo/common/assets.js index f735306006..4efefb8c32 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -294,6 +294,15 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.SUBTITLES) .addFeature(shakaAssets.Feature.SURROUND) .addFeature(shakaAssets.Feature.OFFLINE), + new ShakaDemoAssetInfo( + /* name= */ 'Angel One (HLS, MP4, video media playlist only)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/playlist_v-0480p-1000k-libx264.mp4.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.OFFLINE) + .setMediaPlaylistFullMimeType('video/mp4; codecs="avc1.4d401f"'), new ShakaDemoAssetInfo( /* name= */ 'Angel One (HLS, MP4, multilingual, Widevine)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png', diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index 159eddcca1..2b58d068c0 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -107,6 +107,8 @@ shakaDemo.MessageIds = { DRM_SYSTEM: 'DEMO_DRM_SYSTEM', DRM_TAB: 'DEMO_DRM_TAB', EDIT_CUSTOM: 'DEMO_EDIT_CUSTOM', + HLS_FULL_MIME_TYPE: 'DEMO_HLS_FULL_MIME_TYPE', + HLS_TAB: 'DEMO_HLS_TAB', HEADERS_TAB: 'DEMO_HEADERS_TAB', ICON_URL: 'DEMO_ICON_URL', LICENSE_CERTIFICATE_URL: 'DEMO_LICENSE_CERTIFICATE_URL', diff --git a/demo/custom.js b/demo/custom.js index 7ce29cb795..84454b4150 100644 --- a/demo/custom.js +++ b/demo/custom.js @@ -245,6 +245,40 @@ shakaDemo.Custom = class { } + /** + * @param {!ShakaDemoAssetInfo} assetInProgress + * @param {!Array.} inputsToCheck + * @return {!Element} div + * @private + */ + makeAssetDialogContentsHLS_(assetInProgress, inputsToCheck) { + const mediaPlaylistDiv = document.createElement('div'); + const containerStyle = shakaDemo.InputContainer.Style.FLEX; + const container = new shakaDemo.InputContainer( + mediaPlaylistDiv, /* headerText= */ null, containerStyle, + /* docLink= */ null); + container.getClassList().add('wide-input'); + container.setDefaultRowClass('wide-input'); + + const fullMimeTypeSetup = (input, container) => { + if (assetInProgress.mediaPlaylistFullMimeType) { + input.value = assetInProgress.mediaPlaylistFullMimeType; + } + const defaultConfig = shaka.util.PlayerConfiguration.createDefault(); + input.placeholder = defaultConfig.manifest.hls.mediaPlaylistFullMimeType; + }; + const fullMimeTypeOnChange = (input) => { + assetInProgress.setMediaPlaylistFullMimeType(input.value); + }; + const fullMimeTypeName = shakaDemoMain.getLocalizedString( + shakaDemo.MessageIds.HLS_FULL_MIME_TYPE); + this.makeField_( + container, fullMimeTypeName, fullMimeTypeSetup, fullMimeTypeOnChange); + + return mediaPlaylistDiv; + } + + /** * @param {!ShakaDemoAssetInfo} assetInProgress * @param {!Array.} inputsToCheck @@ -674,6 +708,8 @@ shakaDemo.Custom = class { assetInProgress, inputsToCheck); const adsDiv = this.makeAssetDialogContentsAds_( assetInProgress, inputsToCheck); + const hlsDiv = this.makeAssetDialogContentsHLS_( + assetInProgress, inputsToCheck); const extraConfigDiv = this.makeAssetDialogContentsExtra_( assetInProgress, inputsToCheck); const finishDiv = this.makeAssetDialogContentsFinish_( @@ -713,6 +749,8 @@ shakaDemo.Custom = class { shakaDemo.MessageIds.HEADERS_TAB, headersDiv, /* startOn= */ false); addTabButton( shakaDemo.MessageIds.ADS_TAB, adsDiv, /* startOn= */ false); + addTabButton( + shakaDemo.MessageIds.HLS_TAB, hlsDiv, /* startOn= */ false); addTabButton( shakaDemo.MessageIds.EXTRA_TAB, extraConfigDiv, /* startOn= */ false); @@ -722,6 +760,7 @@ shakaDemo.Custom = class { this.dialog_.appendChild(drmDiv); this.dialog_.appendChild(headersDiv); this.dialog_.appendChild(adsDiv); + this.dialog_.appendChild(hlsDiv); this.dialog_.appendChild(extraConfigDiv); this.dialog_.appendChild(finishDiv); this.dialog_.appendChild(iconDiv); diff --git a/demo/locales/en.json b/demo/locales/en.json index b69622cd85..670d8717a0 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -88,6 +88,7 @@ "DEMO_HIGH_DEFINITION": "High definition", "DEMO_HIGH_DEFINITION_SEARCH": "Filters for assets with at least one high-definition video stream.", "DEMO_HLS": "HLS", + "DEMO_HLS_TAB": "HLS", "DEMO_HOME": "HOME", "DEMO_ICON_URL": "Icon URL", "DEMO_IGNORE_DASH_DRM": "Ignore DASH DRM Info", @@ -166,6 +167,7 @@ "DEMO_OFFLINE_SECTION_HEADER": "Offline", "DEMO_FAILURE_MISC": "Shaka Player failed to load! If you are using an ad blocker, try switching to compiled mode at the bottom of the page.", "DEMO_FAILURE_NO_BROWSER_SUPPORT": "Your browser is not supported!", + "DEMO_HLS_FULL_MIME_TYPE": "Full Mime Type for Playing Media Playlists Directly", "DEMO_PLAY": "Play", "DEMO_PLAYREADY": "PlayReady DRM", "DEMO_PREFER_FORCED_SUBS": "Prefer Forced Subs", diff --git a/demo/locales/source.json b/demo/locales/source.json index aa2920a5fb..2e75a0f548 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -355,6 +355,10 @@ "description": "Text that describes an asset that is packaged in an HLS manifest.", "message": "[PROPER_NAME:HLS]" }, + "DEMO_HLS_TAB": { + "description": "The header for a tab within the custom asset creation dialog.", + "message": "[PROPER_NAME:HLS]" + }, "DEMO_HOME": { "description": "A link in the header, that switches to a page configuration for viewing the curated set of front-page content.", "message": "HOME" @@ -667,6 +671,10 @@ "description": "An error displayed if Shaka Player is unable to load due to lack of browser support.", "message": "Your browser is not supported!" }, + "DEMO_HLS_FULL_MIME_TYPE": { + "description": "The label on a field that allows users to provide a full mime type for a custom asset.", + "message": "Full Mime Type for Playing Media Playlists Directly" + }, "DEMO_PLAY": { "description": "A button to play the attached asset.", "message": "Play" diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 87abd72a26..b56db3f62e 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -748,7 +748,8 @@ shaka.extern.DashManifestConfiguration; * ignoreImageStreamFailures: boolean, * defaultAudioCodec: string, * defaultVideoCodec: string, - * ignoreManifestProgramDateTime: boolean + * ignoreManifestProgramDateTime: boolean, + * mediaPlaylistFullMimeType: string * }} * * @property {boolean} ignoreTextStreamFailures @@ -768,6 +769,15 @@ shaka.extern.DashManifestConfiguration; * EXT-X-PROGRAM-DATE-TIME tags in the manifest. * Meant for tags that are incorrect or malformed. * Defaults to false. + * @property {string} mediaPlaylistFullMimeType + * A string containing a full mime type, including both the basic mime type + * and also the codecs. Used when the HLS parser parses a media playlist + * directly, required since all of the mime type and codecs information is + * contained within the master playlist. + * You can use the shaka.util.MimeUtils.getFullType() utility to + * format this value. + * Defaults to + * 'video/mp2t; codecs="avc1.42E01E, mp4a.40.2"'. * @exportDoc */ shaka.extern.HlsManifestConfiguration; diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 5adf6a5976..fe400d5f96 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -216,7 +216,7 @@ shaka.hls.HlsParser = class { this.masterPlaylistUri_ = response.uri; goog.asserts.assert(response.data, 'Response data should be non-null!'); - await this.parseManifest_(response.data); + await this.parseManifest_(response.data, uri); // Start the update timer if we want updates. const delay = this.updatePlaylistDelay_; @@ -401,10 +401,11 @@ shaka.hls.HlsParser = class { * Parses the manifest. * * @param {BufferSource} data + * @param {string} uri * @return {!Promise} * @private */ - async parseManifest_(data) { + async parseManifest_(data, uri) { const Utils = shaka.hls.Utils; goog.asserts.assert(this.masterPlaylistUri_, @@ -413,63 +414,107 @@ shaka.hls.HlsParser = class { const playlist = this.manifestTextParser_.parsePlaylist( data, this.masterPlaylistUri_); - // We don't support directly providing a Media Playlist. - // See the error code for details. - if (playlist.type != shaka.hls.PlaylistType.MASTER) { - throw new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.MANIFEST, - shaka.util.Error.Code.HLS_MASTER_PLAYLIST_NOT_PROVIDED); - } - /** @type {!Array.} */ const variablesTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-DEFINE'); this.parseMasterVariables_(variablesTags); - /** @type {!Array.} */ - const mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA'); - /** @type {!Array.} */ - const variantTags = Utils.filterTagsByName( - playlist.tags, 'EXT-X-STREAM-INF'); - /** @type {!Array.} */ - const imageTags = Utils.filterTagsByName( - playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); - - this.parseCodecs_(variantTags); - - /** @type {!Array.} */ - const sesionDataTags = - Utils.filterTagsByName(playlist.tags, 'EXT-X-SESSION-DATA'); - for (const tag of sesionDataTags) { - const id = tag.getAttributeValue('DATA-ID'); - const uri = tag.getAttributeValue('URI'); - const language = tag.getAttributeValue('LANGUAGE'); - const value = tag.getAttributeValue('VALUE'); - const data = (new Map()).set('id', id); - if (uri) { - data.set('uri', - shaka.hls.Utils.constructAbsoluteUri(this.masterPlaylistUri_, uri)); - } - if (language) { - data.set('language', language); - } - if (value) { - data.set('value', value); - } - const event = new shaka.util.FakeEvent('sessiondata', data); - if (this.playerInterface_) { - this.playerInterface_.onEvent(event); + /** @type {!Array.} */ + let variants = []; + /** @type {!Array.} */ + let textStreams = []; + /** @type {!Array.} */ + let imageStreams = []; + + // Parsing a media playlist results in a single-variant stream. + if (playlist.type == shaka.hls.PlaylistType.MEDIA) { + // Get necessary info for this stream, from the config. These are things + // we would normally find from the master playlist (e.g. from values on + // EXT-X-MEDIA tags). + const fullMimeType = this.config_.hls.mediaPlaylistFullMimeType; + const mimeType = shaka.util.MimeUtils.getBasicType(fullMimeType); + const type = mimeType.split('/')[0]; + const codecs = shaka.util.MimeUtils.getCodecs(fullMimeType); + + // Some values we cannot figure out, and aren't important enough to ask + // the user to provide through config values. A lot of these are only + // relevant to ABR, which isn't necessary if there's only one variant. + // So these unknowns should be set to false or null, largely. + const language = ''; + const channelsCount = null; + const spatialAudio = false; + const characteristics = null; + const closedCaptions = new Map(); + const bandwidth = 0; + const forced = false; // Only relevant for text. + const primary = true; // This is the only stream! + const name = 'Media Playlist'; + + // Make the stream info, with those values. + const streamInfo = await this.convertParsedPlaylistIntoStreamInfo_( + playlist, uri, uri, codecs, type, language, primary, name, + channelsCount, closedCaptions, characteristics, forced, spatialAudio, + bandwidth, mimeType); + goog.asserts.assert(streamInfo != null, 'StreamInfo is null!'); + this.uriToStreamInfosMap_.set(uri, streamInfo); + + // Wrap the stream from that stream info with a variant. + variants.push({ + id: 0, + language: 'und', + primary: true, + audio: type == 'audio' ? streamInfo.stream : null, + video: type == 'video' ? streamInfo.stream : null, + bandwidth: 0, + allowedByApplication: true, + allowedByKeySystem: true, + decodingInfos: [], + }); + } else { + /** @type {!Array.} */ + const mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA'); + /** @type {!Array.} */ + const variantTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-STREAM-INF'); + /** @type {!Array.} */ + const imageTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); + + this.parseCodecs_(variantTags); + + /** @type {!Array.} */ + const sesionDataTags = + Utils.filterTagsByName(playlist.tags, 'EXT-X-SESSION-DATA'); + for (const tag of sesionDataTags) { + const id = tag.getAttributeValue('DATA-ID'); + const uri = tag.getAttributeValue('URI'); + const language = tag.getAttributeValue('LANGUAGE'); + const value = tag.getAttributeValue('VALUE'); + const data = (new Map()).set('id', id); + if (uri) { + data.set('uri', shaka.hls.Utils.constructAbsoluteUri( + this.masterPlaylistUri_, uri)); + } + if (language) { + data.set('language', language); + } + if (value) { + data.set('value', value); + } + const event = new shaka.util.FakeEvent('sessiondata', data); + if (this.playerInterface_) { + this.playerInterface_.onEvent(event); + } } - } - // Parse audio and video media tags first, so that we can extract segment - // start time from audio/video streams and reuse for text streams. - await this.createStreamInfosFromMediaTags_(mediaTags); - this.parseClosedCaptions_(mediaTags); - const variants = await this.createVariantsForTags_(variantTags); - const textStreams = await this.parseTexts_(mediaTags); - const imageStreams = await this.parseImages_(imageTags); + // Parse audio and video media tags first, so that we can extract segment + // start time from audio/video streams and reuse for text streams. + await this.createStreamInfosFromMediaTags_(mediaTags); + this.parseClosedCaptions_(mediaTags); + variants = await this.createVariantsForTags_(variantTags); + textStreams = await this.parseTexts_(mediaTags); + imageStreams = await this.parseImages_(imageTags); + } // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { @@ -1413,6 +1458,35 @@ shaka.hls.HlsParser = class { const playlist = this.manifestTextParser_.parsePlaylist( response.data, absoluteMediaPlaylistUri); + return this.convertParsedPlaylistIntoStreamInfo_(playlist, + verbatimMediaPlaylistUri, absoluteMediaPlaylistUri, codecs, type, + language, primary, name, channelsCount, closedCaptions, characteristics, + forced, spatialAudio, bandwidth); + } + + /** + * @param {!shaka.hls.Playlist} playlist + * @param {string} verbatimMediaPlaylistUri + * @param {string} absoluteMediaPlaylistUri + * @param {string} codecs + * @param {string} type + * @param {string} language + * @param {boolean} primary + * @param {?string} name + * @param {?number} channelsCount + * @param {Map.} closedCaptions + * @param {?string} characteristics + * @param {boolean} forced + * @param {boolean} spatialAudio + * @param {(number|undefined)} bandwidth + * @param {(string|undefined)} mimeType + * @return {!Promise.} + * @private + */ + async convertParsedPlaylistIntoStreamInfo_(playlist, verbatimMediaPlaylistUri, + absoluteMediaPlaylistUri, codecs, type, language, primary, name, + channelsCount, closedCaptions, characteristics, forced, spatialAudio, + bandwidth = undefined, mimeType = undefined) { if (playlist.type != shaka.hls.PlaylistType.MEDIA) { // EXT-X-MEDIA and EXT-X-IMAGE-STREAM-INF tags should point to media // playlists. @@ -1433,9 +1507,10 @@ shaka.hls.HlsParser = class { this.determinePresentationType_(playlist); - /** @type {string} */ - const mimeType = await this.guessMimeType_(type, codecs, playlist, - mediaVariables); + if (!mimeType) { + mimeType = await this.guessMimeType_(type, codecs, playlist, + mediaVariables); + } /** @type {!Array.} */ const drmTags = []; diff --git a/lib/util/error.js b/lib/util/error.js index 99847cbacc..4830701d07 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -581,13 +581,7 @@ shaka.util.Error.Code = { // RETIRED: 'HLS_COULD_NOT_GUESS_MIME_TYPE': 4021, - /** - * No Master Playlist has been provided. Master playlist provides - * vital information about the streams (like codecs) that is - * required for MediaSource. We don't support directly providing - * a Media Playlist. - */ - 'HLS_MASTER_PLAYLIST_NOT_PROVIDED': 4022, + // RETIRED: 'HLS_MASTER_PLAYLIST_NOT_PROVIDED': 4022, /** * One of the required attributes was not provided, so the diff --git a/lib/util/mime_utils.js b/lib/util/mime_utils.js index a9d3523ff5..8183e50448 100644 --- a/lib/util/mime_utils.js +++ b/lib/util/mime_utils.js @@ -11,6 +11,7 @@ goog.require('shaka.media.Transmuxer'); /** * @summary A set of utility functions for dealing with MIME types. + * @export */ shaka.util.MimeUtils = class { /** @@ -20,6 +21,7 @@ shaka.util.MimeUtils = class { * @param {string} mimeType * @param {string=} codecs * @return {string} + * @export */ static getFullType(mimeType, codecs) { let fullMimeType = mimeType; diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index d5e37aff52..4ca307f179 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -30,7 +30,10 @@ goog.require('shaka.util.Platform'); * @export */ shaka.util.PlayerConfiguration = class { - /** @return {shaka.extern.PlayerConfiguration} */ + /** + * @return {shaka.extern.PlayerConfiguration} + * @export + */ static createDefault() { // This is a relatively safe default in the absence of clues from the // browser. For slower connections, the default estimate may be too high. @@ -124,6 +127,8 @@ shaka.util.PlayerConfiguration = class { defaultAudioCodec: 'mp4a.40.2', defaultVideoCodec: 'avc1.42E01E', ignoreManifestProgramDateTime: false, + mediaPlaylistFullMimeType: + 'video/mp2t; codecs="avc1.42E01E, mp4a.40.2"', }, }; diff --git a/test/demo/demo_unit.js b/test/demo/demo_unit.js index b493f7f151..bd1afa4806 100644 --- a/test/demo/demo_unit.js +++ b/test/demo/demo_unit.js @@ -98,7 +98,8 @@ describe('Demo', () => { .add('preferredVariantRole') .add('playRangeStart') .add('playRangeEnd') - .add('manifest.dash.keySystemsByURI'); + .add('manifest.dash.keySystemsByURI') + .add('manifest.hls.mediaPlaylistFullMimeType'); /** * @param {!Object} section diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 0b92a1ec63..9f11607459 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -3354,4 +3354,54 @@ describe('HlsParser', () => { jasmine.objectContaining(eventValue3)); }); }); + + it('parses media playlists directly', async () => { + const media = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.sequenceMode = true; + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.mime('video/mp2t', 'avc1.42E01E, mp4a.40.2'); + }); + }); + }); + + await testHlsParser(media, '', manifest); + }); + + it('honors hls.mediaPlaylistFullMimeType', async () => { + const media = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.hls.mediaPlaylistFullMimeType = 'audio/webm; codecs="vorbis"'; + parser.configure(config); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.sequenceMode = true; + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.mime('audio/webm', 'vorbis'); + }); + }); + }); + + await testHlsParser(media, '', manifest); + }); });