shaka-player
Version:
DASH/EME video player library
1,386 lines (1,226 loc) • 108 kB
JavaScript
/*! @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