UNPKG

shaka-player

Version:
1,386 lines (1,226 loc) 108 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.hls.HlsParser'); goog.require('goog.Uri'); goog.require('goog.asserts'); goog.require('shaka.hls.ManifestTextParser'); goog.require('shaka.hls.Playlist'); goog.require('shaka.hls.PlaylistType'); goog.require('shaka.hls.Tag'); goog.require('shaka.hls.Utils'); goog.require('shaka.log'); goog.require('shaka.media.DrmEngine'); goog.require('shaka.media.InitSegmentReference'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.SegmentIndex'); goog.require('shaka.media.SegmentReference'); goog.require('shaka.net.DataUriPlugin'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.DataViewReader'); goog.require('shaka.util.Error'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.Functional'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.Mp4BoxParsers'); goog.require('shaka.util.Networking'); goog.require('shaka.util.OperationManager'); goog.require('shaka.util.Pssh'); goog.require('shaka.util.Timer'); goog.requireType('shaka.hls.Segment'); /** * HLS parser. * * @implements {shaka.extern.ManifestParser} * @export */ shaka.hls.HlsParser = class { /** * Creates an Hls Parser object. */ constructor() { /** @private {?shaka.extern.ManifestParser.PlayerInterface} */ this.playerInterface_ = null; /** @private {?shaka.extern.ManifestConfiguration} */ this.config_ = null; /** @private {number} */ this.globalId_ = 1; /** @private {!Map.<string, string>} */ this.globalVariables_ = new Map(); /** * A map from group id to stream infos created from the media tags. * @private {!Map.<string, !Array.<?shaka.hls.HlsParser.StreamInfo>>} */ this.groupIdToStreamInfosMap_ = new Map(); /** * The values are strings of the form "<VIDEO URI> - <AUDIO URI>", * where the URIs are the verbatim media playlist URIs as they appeared in * the master playlist. * * Used to avoid duplicates that vary only in their text stream. * * @private {!Set.<string>} */ this.variantUriSet_ = new Set(); /** * A map from (verbatim) media playlist URI to stream infos representing the * playlists. * * On update, used to iterate through and update from media playlists. * * On initial parse, used to iterate through and determine minimum * timestamps, offsets, and to handle TS rollover. * * During parsing, used to avoid duplicates in the async methods * createStreamInfoFromMediaTag_, createStreamInfoFromImageTag_ and * createStreamInfoFromVariantTag_. * * During parsing of updates, used by getStartTime_ to determine the start * time of the first segment from existing segment references. * * @private {!Map.<string, shaka.hls.HlsParser.StreamInfo>} */ this.uriToStreamInfosMap_ = new Map(); /** @private {?shaka.media.PresentationTimeline} */ this.presentationTimeline_ = null; /** * The master playlist URI, after redirects. * * @private {string} */ this.masterPlaylistUri_ = ''; /** @private {shaka.hls.ManifestTextParser} */ this.manifestTextParser_ = new shaka.hls.ManifestTextParser(); /** * This is the number of seconds we want to wait between finishing a * manifest update and starting the next one. This will be set when we parse * the manifest. * * @private {number} */ this.updatePlaylistDelay_ = 0; /** * This timer is used to trigger the start of a manifest update. A manifest * update is async. Once the update is finished, the timer will be restarted * to trigger the next update. The timer will only be started if the content * is live content. * * @private {shaka.util.Timer} */ this.updatePlaylistTimer_ = new shaka.util.Timer(() => { this.onUpdate_(); }); /** @private {shaka.hls.HlsParser.PresentationType_} */ this.presentationType_ = shaka.hls.HlsParser.PresentationType_.VOD; /** @private {?shaka.extern.Manifest} */ this.manifest_ = null; /** @private {number} */ this.maxTargetDuration_ = 0; /** @private {number} */ this.minTargetDuration_ = Infinity; /** Partial segments target duration. * @private {number} */ this.partialTargetDuration_ = 0; /** @private {number} */ this.lowLatencyPresentationDelay_ = 0; /** @private {shaka.util.OperationManager} */ this.operationManager_ = new shaka.util.OperationManager(); /** @private {!Array.<!Array.<!shaka.media.SegmentReference>>} */ this.segmentsToNotifyByStream_ = []; /** A map from closed captions' group id, to a map of closed captions info. * {group id -> {closed captions channel id -> language}} * @private {Map.<string, Map.<string, string>>} */ this.groupIdToClosedCaptionsMap_ = new Map(); /** True if some of the variants in the playlist is encrypted with AES-128. * @private {boolean} */ this.aesEncrypted_ = false; /** @private {Map.<string, string>} */ this.groupIdToCodecsMap_ = new Map(); /** @private {?number} */ this.playlistStartTime_ = null; /** A cache mapping EXT-X-MAP tag info to the InitSegmentReference created * from the tag. * The key is a string combining the EXT-X-MAP tag's absolute uri, and * its BYTERANGE if available. * {!Map.<string, !shaka.media.InitSegmentReference>} */ this.mapTagToInitSegmentRefMap_ = new Map(); /** * A cache mapping a discontinuity sequence number of a segment with * EXT-X-DISCONTINUITY tag into its timestamp offset. * Key: the discontinuity sequence number of a segment * Value: the segment reference's timestamp offset. * {!Map.<number, number>} */ this.discontinuityToTso_ = new Map(); /** @private {boolean} */ this.lowLatencyMode_ = false; } /** * @override * @exportInterface */ configure(config) { this.config_ = config; } /** * @override * @exportInterface */ async start(uri, playerInterface) { goog.asserts.assert(this.config_, 'Must call configure() before start()!'); this.playerInterface_ = playerInterface; this.lowLatencyMode_ = playerInterface.isLowLatencyMode(); const response = await this.requestManifest_(uri); // Record the master playlist URI after redirects. this.masterPlaylistUri_ = response.uri; goog.asserts.assert(response.data, 'Response data should be non-null!'); await this.parseManifest_(response.data); // Start the update timer if we want updates. const delay = this.updatePlaylistDelay_; if (delay > 0) { this.updatePlaylistTimer_.tickAfter(/* seconds= */ delay); } goog.asserts.assert(this.manifest_, 'Manifest should be non-null'); return this.manifest_; } /** * @override * @exportInterface */ stop() { // Make sure we don't update the manifest again. Even if the timer is not // running, this is safe to call. if (this.updatePlaylistTimer_) { this.updatePlaylistTimer_.stop(); this.updatePlaylistTimer_ = null; } /** @type {!Array.<!Promise>} */ const pending = []; if (this.operationManager_) { pending.push(this.operationManager_.destroy()); this.operationManager_ = null; } this.playerInterface_ = null; this.config_ = null; this.variantUriSet_.clear(); this.manifest_ = null; this.uriToStreamInfosMap_.clear(); this.groupIdToStreamInfosMap_.clear(); this.groupIdToCodecsMap_.clear(); this.globalVariables_.clear(); return Promise.all(pending); } /** * @override * @exportInterface */ async update() { if (!this.isLive_()) { return; } /** @type {!Array.<!Promise>} */ const updates = []; // Reset the start time for the new media playlist. this.playlistStartTime_ = null; const streamInfos = Array.from(this.uriToStreamInfosMap_.values()); // Wait for the first stream info created, so that the start time is fetched // and can be reused. if (streamInfos.length) { await this.updateStream_(streamInfos[0]); } for (let i = 1; i < streamInfos.length; i++) { updates.push(this.updateStream_(streamInfos[i])); } await Promise.all(updates); } /** * Updates a stream. * * @param {!shaka.hls.HlsParser.StreamInfo} streamInfo * @return {!Promise} * @private */ async updateStream_(streamInfo) { const PresentationType = shaka.hls.HlsParser.PresentationType_; const manifestUri = streamInfo.absoluteMediaPlaylistUri; const uriObj = new goog.Uri(manifestUri); if (this.lowLatencyMode_ && streamInfo.canSkipSegments) { // Enable delta updates. This will replace older segments with // 'EXT-X-SKIP' tag in the media playlist. uriObj.setQueryData(new goog.Uri.QueryData('_HLS_skip=YES')); } const response = await this.requestManifest_(uriObj.toString()); /** @type {shaka.hls.Playlist} */ const playlist = this.manifestTextParser_.parsePlaylist( response.data, response.uri); if (playlist.type != shaka.hls.PlaylistType.MEDIA) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.HLS_INVALID_PLAYLIST_HIERARCHY); } /** @type {!Array.<!shaka.hls.Tag>} */ const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags, 'EXT-X-DEFINE'); const mediaVariables = this.parseMediaVariables_(variablesTags); const stream = streamInfo.stream; const segments = await this.createSegments_( streamInfo.verbatimMediaPlaylistUri, playlist, stream.type, stream.mimeType, streamInfo.mediaSequenceToStartTime, mediaVariables, streamInfo.discontinuityToMediaSequence); stream.segmentIndex.mergeAndEvict( segments, this.presentationTimeline_.getSegmentAvailabilityStart()); if (segments.length) { const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber( playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0); const playlistStartTime = streamInfo.mediaSequenceToStartTime.get( mediaSequenceNumber); stream.segmentIndex.evict(playlistStartTime); } const newestSegment = segments[segments.length - 1]; goog.asserts.assert(newestSegment, 'Should have segments!'); // Once the last segment has been added to the playlist, // #EXT-X-ENDLIST tag will be appended. // If that happened, treat the rest of the EVENT presentation as VOD. const endListTag = shaka.hls.Utils.getFirstTagWithName(playlist.tags, 'EXT-X-ENDLIST'); if (endListTag) { // Convert the presentation to VOD and set the duration to the last // segment's end time. this.setPresentationType_(PresentationType.VOD); this.presentationTimeline_.setDuration(newestSegment.endTime); } } /** * @override * @exportInterface */ onExpirationUpdated(sessionId, expiration) { // No-op } /** * Parses the manifest. * * @param {BufferSource} data * @return {!Promise} * @private */ async parseManifest_(data) { const Utils = shaka.hls.Utils; goog.asserts.assert(this.masterPlaylistUri_, 'Master playlist URI must be set before calling parseManifest_!'); 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.<!shaka.hls.Tag>} */ const variablesTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-DEFINE'); this.parseMasterVariables_(variablesTags); /** @type {!Array.<!shaka.hls.Tag>} */ const mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA'); /** @type {!Array.<!shaka.hls.Tag>} */ const variantTags = Utils.filterTagsByName( playlist.tags, 'EXT-X-STREAM-INF'); /** @type {!Array.<!shaka.hls.Tag>} */ const imageTags = Utils.filterTagsByName( playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); this.parseCodecs_(variantTags); /** @type {!Array.<!shaka.hls.Tag>} */ 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); // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.PLAYER, shaka.util.Error.Code.OPERATION_ABORTED); } if (this.aesEncrypted_ && variants.length == 0) { // We do not support AES-128 encryption with HLS yet. Variants is null // when the playlist is encrypted with AES-128. shaka.log.info('No stream is created, because we don\'t support AES-128', 'encryption yet'); throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, shaka.util.Error.Category.MANIFEST, shaka.util.Error.Code.HLS_AES_128_ENCRYPTION_NOT_SUPPORTED); } // Find the min and max timestamp of the earliest segment in all streams. // Find the minimum duration of all streams as well. let minFirstTimestamp = Infinity; let minDuration = Infinity; for (const streamInfo of this.uriToStreamInfosMap_.values()) { minFirstTimestamp = Math.min(minFirstTimestamp, streamInfo.minTimestamp); if (streamInfo.stream.type != 'text') { minDuration = Math.min(minDuration, streamInfo.maxTimestamp - streamInfo.minTimestamp); } } // This assert is our own sanity check. goog.asserts.assert(this.presentationTimeline_ == null, 'Presentation timeline created early!'); this.createPresentationTimeline_(); // This assert satisfies the compiler that it is not null for the rest of // the method. goog.asserts.assert(this.presentationTimeline_, 'Presentation timeline not created!'); if (this.isLive_()) { // The HLS spec (RFC 8216) states in 6.3.4: // "the client MUST wait for at least the target duration before // attempting to reload the Playlist file again". // For LL-HLS, the server must add a new partial segment to the Playlist // every part target duration. this.updatePlaylistDelay_ = this.minTargetDuration_; // The spec says nothing much about seeking in live content, but Safari's // built-in HLS implementation does not allow it. Therefore we will set // the availability window equal to the presentation delay. The player // will be able to buffer ahead three segments, but the seek window will // be zero-sized. const PresentationType = shaka.hls.HlsParser.PresentationType_; if (this.presentationType_ == PresentationType.LIVE) { // This defaults to the presentation delay, which has the effect of // making the live stream unseekable. This is consistent with Apple's // HLS implementation. let segmentAvailabilityDuration = this.presentationTimeline_.getDelay(); // The app can override that with a longer duration, to allow seeking. if (!isNaN(this.config_.availabilityWindowOverride)) { segmentAvailabilityDuration = this.config_.availabilityWindowOverride; } this.presentationTimeline_.setSegmentAvailabilityDuration( segmentAvailabilityDuration); } } else { // For VOD/EVENT content, offset everything back to 0. // Use the minimum timestamp as the offset for all streams. // Use the minimum duration as the presentation duration. this.presentationTimeline_.setDuration(minDuration); // Use a negative offset to adjust towards 0. this.presentationTimeline_.offset(-minFirstTimestamp); for (const streamInfo of this.uriToStreamInfosMap_.values()) { // The segments were created with actual media times, rather than // presentation-aligned times, so offset them all now. streamInfo.stream.segmentIndex.offset(-minFirstTimestamp); // Finally, fit the segments to the playlist duration. streamInfo.stream.segmentIndex.fit(/* periodStart= */ 0, minDuration); } } // Now that the content has been fit, notify segments. this.segmentsToNotifyByStream_ = []; const streamsToNotify = []; for (const variant of variants) { for (const stream of [variant.video, variant.audio]) { if (stream) { streamsToNotify.push(stream); } } } await Promise.all(streamsToNotify.map(async (stream) => { await stream.createSegmentIndex(); })); for (const stream of streamsToNotify) { this.segmentsToNotifyByStream_.push(stream.segmentIndex.references); } this.notifySegments_(); // This asserts that the live edge is being calculated from segment times. // For VOD and event streams, this check should still pass. goog.asserts.assert( !this.presentationTimeline_.usingPresentationStartTime(), 'We should not be using the presentation start time in HLS!'); this.manifest_ = { presentationTimeline: this.presentationTimeline_, variants, textStreams, imageStreams, offlineSessionIds: [], minBufferTime: 0, }; this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_); } /** * Get the variables of each variant tag, and store in a map. * @param {!Array.<!shaka.hls.Tag>} tags Variant tags from the playlist. * @private */ parseMasterVariables_(tags) { for (const variableTag of tags) { const name = variableTag.getAttributeValue('NAME'); const value = variableTag.getAttributeValue('VALUE'); if (name && value) { if (!this.globalVariables_.has(name)) { this.globalVariables_.set(name, value); } } } } /** * Get the variables of each variant tag, and store in a map. * @param {!Array.<!shaka.hls.Tag>} tags Variant tags from the playlist. * @return {!Map.<string, string>} * @private */ parseMediaVariables_(tags) { const mediaVariables = new Map(); for (const variableTag of tags) { const name = variableTag.getAttributeValue('NAME'); const value = variableTag.getAttributeValue('VALUE'); const mediaImport = variableTag.getAttributeValue('IMPORT'); if (name && value) { mediaVariables.set(name, value); } if (mediaImport) { const globalValue = this.globalVariables_.get(mediaImport); if (globalValue) { mediaVariables.set(mediaImport, globalValue); } } } return mediaVariables; } /** * Get the codecs of each variant tag, and store in a map from * audio/video/subtitle group id to the codecs arraylist. * @param {!Array.<!shaka.hls.Tag>} tags Variant tags from the playlist. * @private */ parseCodecs_(tags) { const ContentType = shaka.util.ManifestParserUtils.ContentType; for (const variantTag of tags) { const audioGroupId = variantTag.getAttributeValue('AUDIO'); const videoGroupId = variantTag.getAttributeValue('VIDEO'); const subGroupId = variantTag.getAttributeValue('SUBTITLES'); const allCodecs = this.getCodecsForVariantTag_(variantTag); if (subGroupId) { const textCodecs = shaka.util.ManifestParserUtils.guessCodecsSafe( ContentType.TEXT, allCodecs); goog.asserts.assert(textCodecs != null, 'Text codecs should be valid.'); this.groupIdToCodecsMap_.set(subGroupId, textCodecs); shaka.util.ArrayUtils.remove(allCodecs, textCodecs); } if (audioGroupId) { const codecs = shaka.util.ManifestParserUtils.guessCodecs( ContentType.AUDIO, allCodecs); this.groupIdToCodecsMap_.set(audioGroupId, codecs); } if (videoGroupId) { const codecs = shaka.util.ManifestParserUtils.guessCodecs( ContentType.VIDEO, allCodecs); this.groupIdToCodecsMap_.set(videoGroupId, codecs); } } } /** * Parse Subtitles and Closed Captions from 'EXT-X-MEDIA' tags. * Create text streams for Subtitles, but not Closed Captions. * * @param {!Array.<!shaka.hls.Tag>} mediaTags Media tags from the playlist. * @return {!Promise.<!Array.<!shaka.extern.Stream>>} * @private */ async parseTexts_(mediaTags) { // Create text stream for each Subtitle media tag. const subtitleTags = shaka.hls.Utils.filterTagsByType(mediaTags, 'SUBTITLES'); const textStreamPromises = subtitleTags.map(async (tag) => { const disableText = this.config_.disableText; if (disableText) { return null; } try { const streamInfo = await this.createStreamInfoFromMediaTag_(tag); goog.asserts.assert( streamInfo, 'Should always have a streamInfo for text'); return streamInfo.stream; } catch (e) { if (this.config_.hls.ignoreTextStreamFailures) { return null; } throw e; } }); const textStreams = await Promise.all(textStreamPromises); // Set the codecs for text streams. for (const tag of subtitleTags) { const groupId = tag.getRequiredAttrValue('GROUP-ID'); const codecs = this.groupIdToCodecsMap_.get(groupId); if (codecs) { const textStreamInfos = this.groupIdToStreamInfosMap_.get(groupId); if (textStreamInfos) { for (const textStreamInfo of textStreamInfos) { textStreamInfo.stream.codecs = codecs; } } } } // Do not create text streams for Closed captions. return textStreams.filter((s) => s); } /** * @param {!Array.<!shaka.hls.Tag>} imageTags from the playlist. * @return {!Promise.<!Array.<!shaka.extern.Stream>>} * @private */ async parseImages_(imageTags) { // Create image stream for each image tag. const imageStreamPromises = imageTags.map(async (tag) => { const disableThumbnails = this.config_.disableThumbnails; if (disableThumbnails) { return null; } try { const streamInfo = await this.createStreamInfoFromImageTag_(tag); goog.asserts.assert( streamInfo, 'Should always have a streamInfo for image'); return streamInfo.stream; } catch (e) { if (this.config_.hls.ignoreImageStreamFailures) { return null; } throw e; } }); const imageStreams = await Promise.all(imageStreamPromises); return imageStreams.filter((s) => s); } /** * @param {!Array.<!shaka.hls.Tag>} mediaTags Media tags from the playlist. * @private */ async createStreamInfosFromMediaTags_(mediaTags) { // Filter out subtitles and media tags without uri. mediaTags = mediaTags.filter((tag) => { const uri = tag.getAttributeValue('URI') || ''; const type = tag.getAttributeValue('TYPE'); return type != 'SUBTITLES' && uri != ''; }); // Create stream info for each audio / video media tag. // Wait for the first stream info created, so that the start time is fetched // and can be reused. if (mediaTags.length) { await this.createStreamInfoFromMediaTag_(mediaTags[0]); } const promises = mediaTags.slice(1).map((tag) => { return this.createStreamInfoFromMediaTag_(tag); }); await Promise.all(promises); } /** * @param {!Array.<!shaka.hls.Tag>} tags Variant tags from the playlist. * @return {!Promise.<!Array.<!shaka.extern.Variant>>} * @private */ async createVariantsForTags_(tags) { // Create variants for each variant tag. const variantsPromises = tags.map(async (tag) => { const frameRate = tag.getAttributeValue('FRAME-RATE'); const bandwidth = Number(tag.getAttributeValue('AVERAGE-BANDWIDTH')) || Number(tag.getRequiredAttrValue('BANDWIDTH')); const resolution = tag.getAttributeValue('RESOLUTION'); const [width, height] = resolution ? resolution.split('x') : [null, null]; const videoRange = tag.getAttributeValue('VIDEO-RANGE'); const streamInfos = await this.createStreamInfosForVariantTag_(tag, resolution, frameRate); if (streamInfos) { goog.asserts.assert(streamInfos.audio.length || streamInfos.video.length, 'We should have created a stream!'); return this.createVariants_( streamInfos.audio, streamInfos.video, bandwidth, width, height, frameRate, videoRange); } // We do not support AES-128 encryption with HLS yet. If the streamInfos // is null because of AES-128 encryption, do not create variants for that. return []; }); const allVariants = await Promise.all(variantsPromises); let variants = allVariants.reduce(shaka.util.Functional.collapseArrays, []); // Filter out null variants. variants = variants.filter((variant) => variant != null); return variants; } /** * Create audio and video streamInfos from an 'EXT-X-STREAM-INF' tag and its * related media tags. * * @param {!shaka.hls.Tag} tag * @param {?string} resolution * @param {?string} frameRate * @return {!Promise.<?shaka.hls.HlsParser.StreamInfos>} * @private */ async createStreamInfosForVariantTag_(tag, resolution, frameRate) { const ContentType = shaka.util.ManifestParserUtils.ContentType; /** @type {!Array.<string>} */ let allCodecs = this.getCodecsForVariantTag_(tag); const audioGroupId = tag.getAttributeValue('AUDIO'); const videoGroupId = tag.getAttributeValue('VIDEO'); goog.asserts.assert(audioGroupId == null || videoGroupId == null, 'Unexpected: both video and audio described by media tags!'); const groupId = audioGroupId || videoGroupId; const streamInfos = (groupId && this.groupIdToStreamInfosMap_.has(groupId)) ? this.groupIdToStreamInfosMap_.get(groupId) : []; /** @type {shaka.hls.HlsParser.StreamInfos} */ const res = { audio: audioGroupId ? streamInfos : [], video: videoGroupId ? streamInfos : [], }; // Make an educated guess about the stream type. shaka.log.debug('Guessing stream type for', tag.toString()); let type; let ignoreStream = false; // The Microsoft HLS manifest generators will make audio-only variants // that link to their URI both directly and through an audio tag. // In that case, ignore the local URI and use the version in the // AUDIO tag, so you inherit its language. // As an example, see the manifest linked in issue #860. const streamURI = tag.getRequiredAttrValue('URI'); const hasSameUri = res.audio.find((audio) => { return audio && audio.verbatimMediaPlaylistUri == streamURI; }); const videoCodecs = shaka.util.ManifestParserUtils.guessCodecsSafe( ContentType.VIDEO, allCodecs); const audioCodecs = shaka.util.ManifestParserUtils.guessCodecsSafe( ContentType.AUDIO, allCodecs); if (audioCodecs && !videoCodecs) { // There are no associated media tags, and there's only audio codec, // and no video codec, so it should be audio. type = ContentType.AUDIO; shaka.log.debug('Guessing audio-only.'); } else if (!streamInfos.length && audioCodecs && videoCodecs) { // There are both audio and video codecs, so assume multiplexed content. // Note that the default used when CODECS is missing assumes multiple // (and therefore multiplexed). // Recombine the codec strings into one so that MediaSource isn't // lied to later. (That would trigger an error in Chrome.) shaka.log.debug('Guessing multiplexed audio+video.'); type = ContentType.VIDEO; allCodecs = [[videoCodecs, audioCodecs].join(',')]; } else if (res.audio.length && hasSameUri) { shaka.log.debug('Guessing audio-only.'); type = ContentType.AUDIO; ignoreStream = true; } else if (res.video.length) { // There are associated video streams. Assume this is audio. shaka.log.debug('Guessing audio-only.'); type = ContentType.AUDIO; } else { shaka.log.debug('Guessing video-only.'); type = ContentType.VIDEO; } let streamInfo; if (!ignoreStream) { streamInfo = await this.createStreamInfoFromVariantTag_(tag, allCodecs, type); } if (streamInfo) { res[streamInfo.stream.type] = [streamInfo]; } else if (streamInfo === null) { // Triple-equals for undefined. shaka.log.debug('streamInfo is null'); return null; } this.filterLegacyCodecs_(res); return res; } /** * Get the codecs from the 'EXT-X-STREAM-INF' tag. * * @param {!shaka.hls.Tag} tag * @return {!Array.<string>} codecs * @private */ getCodecsForVariantTag_(tag) { // These are the default codecs to assume if none are specified. // The video codec is H.264, with baseline profile and level 3.0. // http://blog.pearce.org.nz/2013/11/what-does-h264avc1-codecs-parameters.html // The audio codec is "low-complexity" AAC. const defaultCodecsArray = []; if (!this.config_.disableVideo) { defaultCodecsArray.push('avc1.42E01E'); } if (!this.config_.disableAudio) { defaultCodecsArray.push('mp4a.40.2'); } const defaultCodecs = defaultCodecsArray.join(','); const codecsString = tag.getAttributeValue('CODECS', defaultCodecs); // Strip out internal whitespace while splitting on commas: /** @type {!Array.<string>} */ const codecs = codecsString.split(/\s*,\s*/); // Filter out duplicate codecs. const seen = new Set(); const ret = []; for (const codec of codecs) { // HLS says the CODECS field needs to include all codecs that appear in // the content. This means that if the content changes profiles, it should // include both. Since all known browsers support changing profiles // without any other work, just ignore them. See also: // https://github.com/shaka-project/shaka-player/issues/1817 const shortCodec = shaka.util.MimeUtils.getCodecBase(codec); if (!seen.has(shortCodec)) { ret.push(codec); seen.add(shortCodec); } else { shaka.log.debug('Ignoring duplicate codec'); } } return ret; } /** * Get the channel count information for an HLS audio track. * CHANNELS specifies an ordered, "/" separated list of parameters. * If the type is audio, the first parameter will be a decimal integer * specifying the number of independent, simultaneous audio channels. * No other channels parameters are currently defined. * * @param {!shaka.hls.Tag} tag * @return {?number} * @private */ getChannelsCount_(tag) { const channels = tag.getAttributeValue('CHANNELS'); if (!channels) { return null; } const channelcountstring = channels.split('/')[0]; const count = parseInt(channelcountstring, 10); return count; } /** * Get the spatial audio information for an HLS audio track. * In HLS the channels field indicates the number of audio channels that the * stream has (eg: 2). In the case of Dolby Atmos, the complexity is * expressed with the number of channels followed by the word JOC * (eg: 16/JOC), so 16 would be the number of channels (eg: 7.3.6 layout), * and JOC indicates that the stream has spatial audio. * @see https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices/hls_authoring_specification_for_apple_devices_appendixes * * @param {!shaka.hls.Tag} tag * @return {boolean} * @private */ isSpatialAudio_(tag) { const channels = tag.getAttributeValue('CHANNELS'); if (!channels) { return false; } return channels.includes('/JOC'); } /** * Get the closed captions map information for the EXT-X-STREAM-INF tag, to * create the stream info. * @param {!shaka.hls.Tag} tag * @param {string} type * @return {Map.<string, string>} closedCaptions * @private */ getClosedCaptions_(tag, type) { const ContentType = shaka.util.ManifestParserUtils.ContentType; // The attribute of closed captions is optional, and the value may be // 'NONE'. const closedCaptionsAttr = tag.getAttributeValue('CLOSED-CAPTIONS'); // EXT-X-STREAM-INF tags may have CLOSED-CAPTIONS attributes. // The value can be either a quoted-string or an enumerated-string with // the value NONE. If the value is a quoted-string, it MUST match the // value of the GROUP-ID attribute of an EXT-X-MEDIA tag elsewhere in the // Playlist whose TYPE attribute is CLOSED-CAPTIONS. if (type == ContentType.VIDEO && closedCaptionsAttr && closedCaptionsAttr != 'NONE') { return this.groupIdToClosedCaptionsMap_.get(closedCaptionsAttr); } return null; } /** * Get the language value. * * @param {!shaka.hls.Tag} tag * @return {string} * @private */ getLanguage_(tag) { const LanguageUtils = shaka.util.LanguageUtils; const languageValue = tag.getAttributeValue('LANGUAGE') || 'und'; return LanguageUtils.normalize(languageValue); } /** * Get the type value. * Shaka recognizes the content types 'audio', 'video' and 'text'. * The HLS 'subtitles' type needs to be mapped to 'text'. * @param {!shaka.hls.Tag} tag * @return {string} * @private */ getType_(tag) { let type = tag.getRequiredAttrValue('TYPE').toLowerCase(); if (type == 'subtitles') { type = shaka.util.ManifestParserUtils.ContentType.TEXT; } return type; } /** * Filters out unsupported codec strings from an array of stream infos. * @param {shaka.hls.HlsParser.StreamInfos} streamInfos * @private */ filterLegacyCodecs_(streamInfos) { for (const streamInfo of streamInfos.audio.concat(streamInfos.video)) { if (!streamInfo) { continue; } let codecs = streamInfo.stream.codecs.split(','); codecs = codecs.filter((codec) => { // mp4a.40.34 is a nonstandard codec string that is sometimes used in // HLS for legacy reasons. It is not recognized by non-Apple MSE. // See https://bugs.chromium.org/p/chromium/issues/detail?id=489520 // Therefore, ignore this codec string. return codec != 'mp4a.40.34'; }); streamInfo.stream.codecs = codecs.join(','); } } /** * @param {!Array.<shaka.hls.HlsParser.StreamInfo>} audioInfos * @param {!Array.<shaka.hls.HlsParser.StreamInfo>} videoInfos * @param {number} bandwidth * @param {?string} width * @param {?string} height * @param {?string} frameRate * @param {?string} videoRange * @return {!Array.<!shaka.extern.Variant>} * @private */ createVariants_( audioInfos, videoInfos, bandwidth, width, height, frameRate, videoRange) { const ContentType = shaka.util.ManifestParserUtils.ContentType; const DrmEngine = shaka.media.DrmEngine; for (const info of videoInfos) { this.addVideoAttributes_( info.stream, width, height, frameRate, videoRange); } // In case of audio-only or video-only content or the audio/video is // disabled by the config, we create an array of one item containing // a null. This way, the double-loop works for all kinds of content. // NOTE: we currently don't have support for audio-only content. const disableAudio = this.config_.disableAudio; if (!audioInfos.length || disableAudio) { audioInfos = [null]; } const disableVideo = this.config_.disableVideo; if (!videoInfos.length || disableVideo) { videoInfos = [null]; } const variants = []; for (const audioInfo of audioInfos) { for (const videoInfo of videoInfos) { const audioStream = audioInfo ? audioInfo.stream : null; const videoStream = videoInfo ? videoInfo.stream : null; const audioDrmInfos = audioInfo ? audioInfo.stream.drmInfos : null; const videoDrmInfos = videoInfo ? videoInfo.stream.drmInfos : null; const videoStreamUri = videoInfo ? videoInfo.verbatimMediaPlaylistUri : ''; const audioStreamUri = audioInfo ? audioInfo.verbatimMediaPlaylistUri : ''; const variantUriKey = videoStreamUri + ' - ' + audioStreamUri; if (audioStream && videoStream) { if (!DrmEngine.areDrmCompatible(audioDrmInfos, videoDrmInfos)) { shaka.log.warning( 'Incompatible DRM info in HLS variant. Skipping.'); continue; } } if (this.variantUriSet_.has(variantUriKey)) { // This happens when two variants only differ in their text streams. shaka.log.debug( 'Skipping variant which only differs in text streams.'); continue; } // Since both audio and video are of the same type, this assertion will // catch certain mistakes at runtime that the compiler would miss. goog.asserts.assert(!audioStream || audioStream.type == ContentType.AUDIO, 'Audio parameter mismatch!'); goog.asserts.assert(!videoStream || videoStream.type == ContentType.VIDEO, 'Video parameter mismatch!'); const variant = { id: this.globalId_++, language: audioStream ? audioStream.language : 'und', primary: (!!audioStream && audioStream.primary) || (!!videoStream && videoStream.primary), audio: audioStream, video: videoStream, bandwidth, allowedByApplication: true, allowedByKeySystem: true, decodingInfos: [], }; variants.push(variant); this.variantUriSet_.add(variantUriKey); } } return variants; } /** * Parses an array of EXT-X-MEDIA tags, then stores the values of all tags * with TYPE="CLOSED-CAPTIONS" into a map of group id to closed captions. * * @param {!Array.<!shaka.hls.Tag>} mediaTags * @private */ parseClosedCaptions_(mediaTags) { const closedCaptionsTags = shaka.hls.Utils.filterTagsByType(mediaTags, 'CLOSED-CAPTIONS'); for (const tag of closedCaptionsTags) { goog.asserts.assert(tag.name == 'EXT-X-MEDIA', 'Should only be called on media tags!'); const language = this.getLanguage_(tag); // The GROUP-ID value is a quoted-string that specifies the group to which // the Rendition belongs. const groupId = tag.getRequiredAttrValue('GROUP-ID'); // The value of INSTREAM-ID is a quoted-string that specifies a Rendition // within the segments in the Media Playlist. This attribute is REQUIRED // if the TYPE attribute is CLOSED-CAPTIONS. const instreamId = tag.getRequiredAttrValue('INSTREAM-ID'); if (!this.groupIdToClosedCaptionsMap_.get(groupId)) { this.groupIdToClosedCaptionsMap_.set(groupId, new Map()); } this.groupIdToClosedCaptionsMap_.get(groupId).set(instreamId, language); } } /** * Parse EXT-X-MEDIA media tag into a Stream object. * * @param {shaka.hls.Tag} tag * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>} * @private */ async createStreamInfoFromMediaTag_(tag) { goog.asserts.assert(tag.name == 'EXT-X-MEDIA', 'Should only be called on media tags!'); const groupId = tag.getRequiredAttrValue('GROUP-ID'); let codecs = ''; /** @type {string} */ const type = this.getType_(tag); // Text does not require a codec. if (type != shaka.util.ManifestParserUtils.ContentType.TEXT && groupId && this.groupIdToCodecsMap_.has(groupId)) { codecs = this.groupIdToCodecsMap_.get(groupId); } const verbatimMediaPlaylistUri = this.variableSubstitution_( tag.getRequiredAttrValue('URI'), this.globalVariables_); // Check if the stream has already been created as part of another Variant // and return it if it has. if (this.uriToStreamInfosMap_.has(verbatimMediaPlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimMediaPlaylistUri); } const language = this.getLanguage_(tag); const name = tag.getAttributeValue('NAME'); // NOTE: According to the HLS spec, "DEFAULT=YES" requires "AUTOSELECT=YES". // However, we don't bother to validate "AUTOSELECT", since we don't // actually use it in our streaming model, and we treat everything as // "AUTOSELECT=YES". A value of "AUTOSELECT=NO" would imply that it may // only be selected explicitly by the user, and we don't have a way to // represent that in our model. const defaultAttrValue = tag.getAttributeValue('DEFAULT'); const primary = defaultAttrValue == 'YES'; const channelsCount = type == 'audio' ? this.getChannelsCount_(tag) : null; const spatialAudio = type == 'audio' ? this.isSpatialAudio_(tag) : false; const characteristics = tag.getAttributeValue('CHARACTERISTICS'); const forcedAttrValue = tag.getAttributeValue('FORCED'); const forced = forcedAttrValue == 'YES'; // TODO: Should we take into account some of the currently ignored // attributes: INSTREAM-ID, Attribute descriptions: https://bit.ly/2lpjOhj const streamInfo = await this.createStreamInfo_( verbatimMediaPlaylistUri, codecs, type, language, primary, name, channelsCount, /* closedCaptions= */ null, characteristics, forced, spatialAudio); if (this.groupIdToStreamInfosMap_.has(groupId)) { this.groupIdToStreamInfosMap_.get(groupId).push(streamInfo); } else { this.groupIdToStreamInfosMap_.set(groupId, [streamInfo]); } if (streamInfo == null) { return null; } // TODO: This check is necessary because of the possibility of multiple // calls to createStreamInfoFromMediaTag_ before either has resolved. if (this.uriToStreamInfosMap_.has(verbatimMediaPlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimMediaPlaylistUri); } this.uriToStreamInfosMap_.set(verbatimMediaPlaylistUri, streamInfo); return streamInfo; } /** * Parse EXT-X-MEDIA media tag into a Stream object. * * @param {shaka.hls.Tag} tag * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>} * @private */ async createStreamInfoFromImageTag_(tag) { goog.asserts.assert(tag.name == 'EXT-X-IMAGE-STREAM-INF', 'Should only be called on image tags!'); /** @type {string} */ const type = shaka.util.ManifestParserUtils.ContentType.IMAGE; const verbatimImagePlaylistUri = this.variableSubstitution_( tag.getRequiredAttrValue('URI'), this.globalVariables_); const codecs = tag.getAttributeValue('CODECS', 'jpeg') || ''; // Check if the stream has already been created as part of another Variant // and return it if it has. if (this.uriToStreamInfosMap_.has(verbatimImagePlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimImagePlaylistUri); } const language = this.getLanguage_(tag); const name = tag.getAttributeValue('NAME'); const characteristics = tag.getAttributeValue('CHARACTERISTICS'); const streamInfo = await this.createStreamInfo_( verbatimImagePlaylistUri, codecs, type, language, /* primary= */ false, name, /* channelsCount= */ null, /* closedCaptions= */ null, characteristics, /* forced= */ false, /* spatialAudio= */ false); if (streamInfo == null) { return null; } // TODO: This check is necessary because of the possibility of multiple // calls to createStreamInfoFromImageTag_ before either has resolved. if (this.uriToStreamInfosMap_.has(verbatimImagePlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimImagePlaylistUri); } // Parse misc attributes. const resolution = tag.getAttributeValue('RESOLUTION'); if (resolution) { // The RESOLUTION tag represents the resolution of a single thumbnail, not // of the entire sheet at once (like we expect in the output). // So multiply by the layout size. const reference = streamInfo.stream.segmentIndex.get(0); const layout = reference.getTilesLayout(); if (layout) { streamInfo.stream.width = Number(resolution.split('x')[0]) * Number(layout.split('x')[0]); streamInfo.stream.height = Number(resolution.split('x')[1]) * Number(layout.split('x')[1]); // TODO: What happens if there are multiple grids, with different // layout sizes, inside this image stream? } } const bandwidth = tag.getAttributeValue('BANDWIDTH'); if (bandwidth) { streamInfo.stream.bandwidth = Number(bandwidth); } this.uriToStreamInfosMap_.set(verbatimImagePlaylistUri, streamInfo); return streamInfo; } /** * Parse an EXT-X-STREAM-INF media tag into a Stream object. * * @param {!shaka.hls.Tag} tag * @param {!Array.<string>} allCodecs * @param {string} type * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>} * @private */ async createStreamInfoFromVariantTag_(tag, allCodecs, type) { goog.asserts.assert(tag.name == 'EXT-X-STREAM-INF', 'Should only be called on variant tags!'); const verbatimMediaPlaylistUri = this.variableSubstitution_( tag.getRequiredAttrValue('URI'), this.globalVariables_); if (this.uriToStreamInfosMap_.has(verbatimMediaPlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimMediaPlaylistUri); } const closedCaptions = this.getClosedCaptions_(tag, type); const codecs = shaka.util.ManifestParserUtils.guessCodecs(type, allCodecs); const streamInfo = await this.createStreamInfo_(verbatimMediaPlaylistUri, codecs, type, /* language= */ 'und', /* primary= */ false, /* name= */ null, /* channelcount= */ null, closedCaptions, /* characteristics= */ null, /* forced= */ false, /* spatialAudio= */ false); if (streamInfo == null) { return null; } // TODO: This check is necessary because of the possibility of multiple // calls to createStreamInfoFromVariantTag_ before either has resolved. if (this.uriToStreamInfosMap_.has(verbatimMediaPlaylistUri)) { return this.uriToStreamInfosMap_.get(verbatimMediaPlaylistUri); } this.uriToStreamInfosMap_.set(verbatimMediaPlaylistUri, streamInfo); return streamInfo; } /** * @param {string} verbatimMediaPlaylistUri * @param {string} codecs * @param {string} type * @param {string} language * @param {boolean} primary * @param {?string} name * @param {?number} channelsCount * @param {Map.<string, string>} closedCaptions * @param {?string} characteristics * @param {boolean} forced * @param {boolean} spatialAudio * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>} * @private */ async createStreamInfo_(verbatimMediaPlaylistUri, codecs, type, language, primary, name, channelsCount, closedCaptions, characteristics, forced, spatialAudio) { // TODO: Refactor, too many parameters let absoluteMediaPlaylistUri = shaka.hls.Utils.constructAbsoluteUri( this.masterPlaylistUri_, verbatimMediaPlaylistUri); const response = await this.requestManifest_(absoluteMediaPlaylistUri); // Record the final URI after redirects. absoluteMediaPlaylistUri = response.uri; // Record the redirected, final URI of this media playlist when we parse it. /** @type {!shaka.hls.Playlist} */ const playlist = this.manifestTextParser_.parsePlaylist( response.data, absoluteMediaPlaylistUri); if (playlist.type != shaka.hls.PlaylistType.MEDIA) { // EXT-X-MEDIA and EXT-X-IMAGE-STREAM-INF tags should point to media // playlists. throw new shaka.util.Error( shaka.util.Error.Severity.CRITICAL, sh