Files
shaka-player/lib/hls/hls_parser.js
T
Álvaro Velad Galván 28f35c251c feat(lowLatency): Add autoLowLatencyMode config (#2861)
With "lowLatencyMode" enabled, "rebufferingGoal" is set to 0.001, and inaccurateManifestTolerance is set to 0 by default. However, in some cases longer rebufferingGoal helps to avoid new rebuffering.
Besides  "lowLatencyMode", this code change adds the  "autoLowLatencyMode" config. When "lowLatencyMode" config is disabled and "autoLowLatencyMode" config is enabled, and the manifest provides low latency features, we automatically activate the lowLatencyMode.
If "lowLatencyMode" is enabled, "autoLowLatencyMode" has no effect.

Issue #1525
2020-11-12 11:07:19 -08:00

2875 lines
98 KiB
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.Iterables');
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_ 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_) {
// 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);
stream.segmentIndex.merge(segments);
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');
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 = {
'id': id,
};
if (uri) {
data.uri =
shaka.hls.Utils.constructAbsoluteUri(this.masterPlaylistUri_, uri);
}
if (language) {
data.language = language;
}
if (value) {
data.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);
// 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);
}
}
this.manifest_ = {
presentationTimeline: this.presentationTimeline_,
variants,
textStreams,
offlineSessionIds: [],
minBufferTime: 0,
};
this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_);
await this.playerInterface_.filter(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 = this.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 = this.guessCodecs_(ContentType.AUDIO, allCodecs);
this.groupIdToCodecsMap_.set(audioGroupId, codecs);
}
if (videoGroupId) {
const codecs = this.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>} 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.getRequiredAttrValue('BANDWIDTH'));
const resolution = tag.getAttributeValue('RESOLUTION');
const [width, height] = resolution ? resolution.split('x') : [null, null];
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);
}
// 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 = this.guessCodecsSafe_(ContentType.VIDEO, allCodecs);
const hasVideoRelatedInfo = resolution || frameRate || videoCodecs;
if (allCodecs.length == 1 && !hasVideoRelatedInfo) {
// There are no associated media tags, and there's only one codec, and no
// video related information, so it should be audio.
type = ContentType.AUDIO;
shaka.log.debug('Guessing audio-only.');
} else if (!streamInfos.length && allCodecs.length > 1) {
// There are multiple 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 = [allCodecs.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/google/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 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
* @return {!Array.<!shaka.extern.Variant>}
* @private
*/
createVariants_(audioInfos, videoInfos, bandwidth, width, height, frameRate) {
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const DrmEngine = shaka.media.DrmEngine;
for (const info of videoInfos) {
this.addVideoAttributes_(info.stream, width, height, frameRate);
}
// 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,
};
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.getAttribute('DEFAULT');
const primary = defaultAttrValue == 'YES';
const channelsCount = type == 'audio' ? this.getChannelsCount_(tag) : null;
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);
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 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 = this.guessCodecs_(type, allCodecs);
const streamInfo = await this.createStreamInfo_(verbatimMediaPlaylistUri,
codecs, type, /* language= */ 'und', /* primary= */ false,
/* name= */ null, /* channelcount= */ null, closedCaptions,
/* characteristics= */ null, /* forced= */ 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
* @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
* @private
*/
async createStreamInfo_(verbatimMediaPlaylistUri, codecs, type, language,
primary, name, channelsCount, closedCaptions, characteristics, forced) {
// 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 tags should point to media playlists.
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 drmTags = [];
if (playlist.segments) {
for (const segment of playlist.segments) {
const segmentKeyTags = shaka.hls.Utils.filterTagsByName(segment.tags,
'EXT-X-KEY');
drmTags.push(...segmentKeyTags);
}
}
let encrypted = false;
/** @type {!Array.<shaka.extern.DrmInfo>}*/
const drmInfos = [];
const keyIds = new Set();
// TODO: May still need changes to support key rotation.
for (const drmTag of drmTags) {
const method = drmTag.getRequiredAttrValue('METHOD');
if (method != 'NONE') {
encrypted = true;
// We do not support AES-128 encryption with HLS yet. So, do not create
// StreamInfo for the playlist encrypted with AES-128.
// TODO: Remove the error message once we add support for AES-128.
if (method == 'AES-128') {
shaka.log.warning('Unsupported HLS Encryption', method);
this.aesEncrypted_ = true;
return null;
}
const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT');
const drmParser =
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_[keyFormat];
const drmInfo = drmParser ? drmParser(drmTag) : null;
if (drmInfo) {
if (drmInfo.keyIds) {
for (const keyId of drmInfo.keyIds) {
keyIds.add(keyId);
}
}
drmInfos.push(drmInfo);
} else {
shaka.log.warning('Unsupported HLS KEYFORMAT', keyFormat);
}
}
}
if (encrypted && !drmInfos.length) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_KEYFORMATS_NOT_SUPPORTED);
}
/** @type {!Array.<!shaka.hls.Tag>} */
const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags,
'EXT-X-DEFINE');
const mediaVariables = this.parseMediaVariables_(variablesTags);
goog.asserts.assert(playlist.segments != null,
'Media playlist should have segments!');
this.determinePresentationType_(playlist);
/** @type {string} */
const mimeType = await this.guessMimeType_(type, codecs, playlist,
mediaVariables);
// MediaSource expects no codec strings combined with raw formats.
// TODO(#2337): Instead, create a Stream flag indicating a raw format.
if (shaka.hls.HlsParser.RAW_FORMATS_.includes(mimeType)) {
codecs = '';
}
/** @type {!Map.<number, number>} */
const mediaSequenceToStartTime = new Map();
let segments;
try {
segments = await this.createSegments_(verbatimMediaPlaylistUri,
playlist, type, mimeType, mediaSequenceToStartTime, mediaVariables);
} catch (error) {
if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) {
shaka.log.alwaysWarn('Skipping unsupported HLS stream',
mimeType, verbatimMediaPlaylistUri);
return null;
}
throw error;
}
const minTimestamp = segments[0].startTime;
const lastEndTime = segments[segments.length - 1].endTime;
/** @type {!shaka.media.SegmentIndex} */
const segmentIndex = new shaka.media.SegmentIndex(segments);
const kind = (type == shaka.util.ManifestParserUtils.ContentType.TEXT) ?
shaka.util.ManifestParserUtils.TextStreamKind.SUBTITLE : undefined;
const roles = [];
if (characteristics) {
for (const characteristic of characteristics.split(',')) {
roles.push(characteristic);
}
}
/** @type {shaka.extern.Stream} */
const stream = {
id: this.globalId_++,
originalId: name,
createSegmentIndex: () => Promise.resolve(),
segmentIndex,
mimeType,
codecs,
kind,
encrypted,
drmInfos,
keyIds,
language,
label: name, // For historical reasons, since before "originalId".
type,
primary,
// TODO: trick mode
trickModeVideo: null,
emsgSchemeIdUris: null,
frameRate: undefined,
pixelAspectRatio: undefined,
width: undefined,
height: undefined,
bandwidth: undefined,
roles: roles,
forced: forced,
channelsCount,
audioSamplingRate: null,
closedCaptions,
};
return {
stream,
verbatimMediaPlaylistUri,
absoluteMediaPlaylistUri,
minTimestamp,
maxTimestamp: lastEndTime,
mediaSequenceToStartTime,
};
}
/**
* @param {!shaka.hls.Playlist} playlist
* @private
*/
determinePresentationType_(playlist) {
const PresentationType = shaka.hls.HlsParser.PresentationType_;
const presentationTypeTag =
shaka.hls.Utils.getFirstTagWithName(playlist.tags,
'EXT-X-PLAYLIST-TYPE');
const endListTag =
shaka.hls.Utils.getFirstTagWithName(playlist.tags, 'EXT-X-ENDLIST');
const isVod = (presentationTypeTag && presentationTypeTag.value == 'VOD') ||
endListTag;
const isEvent = presentationTypeTag &&
presentationTypeTag.value == 'EVENT' && !isVod;
const isLive = !isVod && !isEvent;
if (isVod) {
this.setPresentationType_(PresentationType.VOD);
} else {
// If it's not VOD, it must be presentation type LIVE or an ongoing EVENT.
if (isLive) {
this.setPresentationType_(PresentationType.LIVE);
} else {
this.setPresentationType_(PresentationType.EVENT);
}
const targetDurationTag = this.getRequiredTag_(playlist.tags,
'EXT-X-TARGETDURATION');
const targetDuration = Number(targetDurationTag.value);
const partialTargetDurationTag =
shaka.hls.Utils.getFirstTagWithName(playlist.tags, 'EXT-X-PART-INF');
// According to the HLS spec, updates should not happen more often than
// once in targetDuration. It also requires us to only update the active
// variant. We might implement that later, but for now every variant
// will be updated. To get the update period, choose the smallest
// targetDuration value across all playlists.
// 1. Update the shortest one to use as update period and segment
// availability time (for LIVE).
if (this.lowLatencyMode_ && partialTargetDurationTag) {
// For low latency streaming, use the partial segment target duration.
this.partialTargetDuration_ = Number(
partialTargetDurationTag.getRequiredAttrValue('PART-TARGET'));
this.minTargetDuration_ = Math.min(
this.partialTargetDuration_, this.minTargetDuration_);
} else {
// For regular HLS, use the target duration of regular segments.
this.minTargetDuration_ = Math.min(
targetDuration, this.minTargetDuration_);
}
// 2. Update the longest target duration if need be to use as a
// presentation delay later.
this.maxTargetDuration_ = Math.max(
targetDuration, this.maxTargetDuration_);
// Use 'PART-HOLD-BACK' as the presentation delay for low latency mode.
if (this.lowLatencyMode_) {
// Get the server-recommended min distance from the live edge.
const serverControlTag = shaka.hls.Utils.getFirstTagWithName(
playlist.tags, 'EXT-X-SERVER-CONTROL');
this.lowLatencyPresentationDelay_ = serverControlTag ? Number(
serverControlTag.getRequiredAttrValue('PART-HOLD-BACK')) : 0;
}
}
}
/**
* @private
*/
createPresentationTimeline_() {
if (this.isLive_()) {
// The live edge will be calculated from segments, so we don't need to
// set a presentation start time. We will assert later that this is
// working as expected.
// The HLS spec (RFC 8216) states in 6.3.3:
//
// "The client SHALL choose which Media Segment to play first ... the
// client SHOULD NOT choose a segment that starts less than three target
// durations from the end of the Playlist file. Doing so can trigger
// playback stalls."
//
// We accomplish this in our DASH-y model by setting a presentation
// delay of configured value, or 3 segments duration if not configured.
// This will be the "live edge" of the presentation.
let presentationDelay;
if (this.config_.defaultPresentationDelay) {
presentationDelay = this.config_.defaultPresentationDelay;
} else if (this.lowLatencyPresentationDelay_) {
presentationDelay = this.lowLatencyPresentationDelay_;
} else {
presentationDelay = this.maxTargetDuration_ * 3;
}
this.presentationTimeline_ = new shaka.media.PresentationTimeline(
/* presentationStartTime= */ 0, /* delay= */ presentationDelay);
this.presentationTimeline_.setStatic(false);
} else {
this.presentationTimeline_ = new shaka.media.PresentationTimeline(
/* presentationStartTime= */ null, /* delay= */ 0);
this.presentationTimeline_.setStatic(true);
}
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!');
}
/**
* Get the InitSegmentReference for a segment if it has a EXT-X-MAP tag.
* @param {string} playlistUri The absolute uri of the media playlist.
* @param {!Array.<!shaka.hls.Tag>} tags Segment tags
* @param {!Map.<string, string>} variables
* @return {shaka.media.InitSegmentReference}
* @private
*/
getInitSegmentReference_(playlistUri, tags, variables) {
/** @type {?shaka.hls.Tag} */
const mapTag = shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-MAP');
if (!mapTag) {
return null;
}
// Map tag example: #EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0"
const verbatimInitSegmentUri = mapTag.getRequiredAttrValue('URI');
const absoluteInitSegmentUri = this.variableSubstitution_(
shaka.hls.Utils.constructAbsoluteUri(
playlistUri, verbatimInitSegmentUri),
variables);
const mapTagKey = [
absoluteInitSegmentUri,
mapTag.getAttributeValue('BYTERANGE', ''),
].join('-');
if (!this.mapTagToInitSegmentRefMap_.has(mapTagKey)) {
const initSegmentRef = this.createInitSegmentReference_(
absoluteInitSegmentUri, mapTag);
this.mapTagToInitSegmentRefMap_.set(mapTagKey, initSegmentRef);
}
return this.mapTagToInitSegmentRefMap_.get(mapTagKey);
}
/**
* Create an InitSegmentReference object for the EXT-X-MAP tag in the media
* playlist.
* @param {string} absoluteInitSegmentUri
* @param {!shaka.hls.Tag} mapTag EXT-X-MAP
* @return {!shaka.media.InitSegmentReference}
* @private
*/
createInitSegmentReference_(absoluteInitSegmentUri, mapTag) {
let startByte = 0;
let endByte = null;
const byterange = mapTag.getAttributeValue('BYTERANGE');
// If a BYTERANGE attribute is not specified, the segment consists
// of the entire resource.
if (byterange) {
const blocks = byterange.split('@');
const byteLength = Number(blocks[0]);
startByte = Number(blocks[1]);
endByte = startByte + byteLength - 1;
}
const initSegmentRef = new shaka.media.InitSegmentReference(
() => [absoluteInitSegmentUri],
startByte,
endByte);
return initSegmentRef;
}
/**
* Parses one shaka.hls.Segment object into a shaka.media.SegmentReference.
*
* @param {shaka.media.InitSegmentReference} initSegmentReference
* @param {shaka.media.SegmentReference} previousReference
* @param {!shaka.hls.Segment} hlsSegment
* @param {number} startTime
* @param {number} timestampOffset
* @param {!Map.<string, string>} variables
* @param {string} absoluteMediaPlaylistUri
* @return {!shaka.media.SegmentReference}
* @private
*/
createSegmentReference_(
initSegmentReference, previousReference, hlsSegment, startTime,
timestampOffset, variables, absoluteMediaPlaylistUri) {
const tags = hlsSegment.tags;
const absoluteSegmentUri = this.variableSubstitution_(
hlsSegment.absoluteUri, variables);
const extinfTag =
shaka.hls.Utils.getFirstTagWithName(tags, 'EXTINF');
let endTime = 0;
let startByte = 0;
let endByte = null;
// Create SegmentReferences for the partial segments.
const partialSegmentRefs = [];
if (this.lowLatencyMode_ && hlsSegment.partialSegments.length) {
const enumerate = (it) => shaka.util.Iterables.enumerate(it);
for (const {i, item} of enumerate(hlsSegment.partialSegments)) {
const pPreviousReference = i == 0 ?
previousReference : partialSegmentRefs[partialSegmentRefs.length - 1];
const pStartTime = (i == 0) ? startTime : pPreviousReference.endTime;
const pDuration = Number(item.getAttributeValue('DURATION'));
// A preload hinted partial segment doesn't have duration information,
// so its startTime and endTime are the same.
const pEndTime = pStartTime + pDuration;
let pStartByte = 0;
let pEndByte = null;
if (item.name == 'EXT-X-PRELOAD-HINT') {
// A preload hinted partial segment may have byterange start info.
const pByterangeStart = item.getAttributeValue('BYTERANGE-START');
pStartByte = pByterangeStart ? Number(pByterangeStart) : 0;
} else {
const pByterange = item.getAttributeValue('BYTERANGE');
[pStartByte, pEndByte] =
this.parseByteRange_(pPreviousReference, pByterange);
}
const pUri = item.getAttributeValue('URI');
const pAbsoluteUri = shaka.hls.Utils.constructAbsoluteUri(
absoluteMediaPlaylistUri, pUri);
const partial = new shaka.media.SegmentReference(
pStartTime,
pEndTime,
() => [pAbsoluteUri],
pStartByte,
pEndByte,
initSegmentReference,
timestampOffset,
/* appendWindowStart= */ 0,
/* appendWindowEnd= */ Infinity);
partialSegmentRefs.push(partial);
} // for-loop of hlsSegment.partialSegments
} else {
// EXTINF tag must be available if the segment has no partial segments.
if (!extinfTag) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_REQUIRED_TAG_MISSING, 'EXTINF');
}
}
// If the segment has EXTINF tag, set the segment's end time, start byte
// and end byte based on the duration and byterange information.
// Otherwise, calculate the end time, start / end byte based on its partial
// segments.
// Note that the sum of partial segments durations may be slightly different
// from the parent segment's duration. In this case, use the duration from
// the parent segment tag.
if (extinfTag) {
// The EXTINF tag format is '#EXTINF:<duration>,[<title>]'.
// We're interested in the duration part.
const extinfValues = extinfTag.value.split(',');
const duration = Number(extinfValues[0]);
endTime = startTime + duration;
} else {
endTime = partialSegmentRefs[partialSegmentRefs.length - 1].endTime;
}
// If the segment has EXT-X-BYTERANGE tag, set the start byte and end byte
// base on the byterange information. If segment has no EXT-X-BYTERANGE tag
// and has partial segments, set the start byte and end byte base on the
// partial segments.
const byterangeTag =
shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-BYTERANGE');
if (byterangeTag) {
[startByte, endByte] =
this.parseByteRange_(previousReference, byterangeTag.value);
} else if (partialSegmentRefs.length) {
startByte = partialSegmentRefs[0].startByte;
endByte = partialSegmentRefs[partialSegmentRefs.length - 1].endByte;
}
return new shaka.media.SegmentReference(
startTime,
endTime,
() => absoluteSegmentUri.length ? [absoluteSegmentUri] : [],
startByte,
endByte,
initSegmentReference,
timestampOffset,
/* appendWindowStart= */ 0,
/* appendWindowEnd= */ Infinity,
partialSegmentRefs,
);
}
/**
* Parse the startByte and endByte.
* @param {shaka.media.SegmentReference} previousReference
* @param {?string} byterange
* @return {!Array.<number>} An array with the start byte and end byte.
* @private
*/
parseByteRange_(previousReference, byterange) {
let startByte = 0;
let endByte = null;
// If BYTERANGE is not specified, the segment consists of the entire
// resource.
if (byterange) {
const blocks = byterange.split('@');
const byteLength = Number(blocks[0]);
if (blocks[1]) {
startByte = Number(blocks[1]);
} else {
goog.asserts.assert(previousReference,
'Cannot refer back to previous HLS segment!');
startByte = previousReference.endByte + 1;
}
endByte = startByte + byteLength - 1;
}
return [startByte, endByte];
}
/** @private */
notifySegments_() {
// The presentation timeline may or may not be set yet.
// If it does not yet exist, hold onto the segments until it does.
if (!this.presentationTimeline_) {
return;
}
for (const segments of this.segmentsToNotifyByStream_) {
this.presentationTimeline_.notifySegments(segments);
}
this.segmentsToNotifyByStream_ = [];
}
/**
* Parses shaka.hls.Segment objects into shaka.media.SegmentReferences.
*
* @param {string} verbatimMediaPlaylistUri
* @param {!shaka.hls.Playlist} playlist
* @param {string} type
* @param {string} mimeType
* @param {!Map.<number, number>} mediaSequenceToStartTime
* @param {!Map.<string, string>} variables
* @return {!Promise<!Array.<!shaka.media.SegmentReference>>}
* @private
*/
async createSegments_(verbatimMediaPlaylistUri, playlist, type, mimeType,
mediaSequenceToStartTime, variables) {
/** @type {Array.<!shaka.hls.Segment>} */
const hlsSegments = playlist.segments;
goog.asserts.assert(hlsSegments.length, 'Playlist should have segments!');
/** @type {shaka.media.InitSegmentReference} */
let initSegmentRef;
// We may need to look at the media itself to determine a segment start
// time.
const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber(
playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0);
const skipTag = shaka.hls.Utils.getFirstTagWithName(playlist.tags,
'EXT-X-SKIP');
const skippedSegments =
skipTag ? Number(skipTag.getAttributeValue('SKIPPED-SEGMENTS')) : 0;
let position = mediaSequenceNumber + skippedSegments;
let firstStartTime;
// For live stream, use the cached value in the mediaSequenceToStartTime
// map if available.
// Since createSegments_() is asynchronous and we are updating the streams
// in parallel, the global playlistStartTime_ may get updated by other
// playlist updates rather than the current one.
if (this.isLive_() && mediaSequenceToStartTime.has(position)) {
firstStartTime = mediaSequenceToStartTime.get(position);
} else {
if (this.playlistStartTime_ == null) {
// For VOD and EVENT playlists, all variants must start at the same
// time, so we can fetch the start time once and reuse for the others.
// This is not guaranteed when updating a LIVE stream. We assume the
// first segment in each live playlist is no more than one segment out
// of sync with the other playlists, so we can fetch the start time for
// once.
initSegmentRef = this.getInitSegmentReference_(
playlist.absoluteUri, hlsSegments[0].tags, variables);
goog.asserts.assert(
type != shaka.util.ManifestParserUtils.ContentType.TEXT,
'Should only get start time from audio or video streams');
this.playlistStartTime_ = await this.getStartTime_(
verbatimMediaPlaylistUri, initSegmentRef, mimeType,
position, /* isDiscontinuity= */ false,
hlsSegments[0], variables);
}
firstStartTime = this.playlistStartTime_;
}
const firstSegmentUri = hlsSegments[0].absoluteUri;
shaka.log.debug('First segment', firstSegmentUri.split('/').pop(),
'starts at', firstStartTime);
let discontintuitySequenceNum = shaka.hls.Utils.getFirstTagWithNameAsNumber(
playlist.tags, 'EXT-X-DISCONTINUITY-SEQUENCE');
let timestampOffset =
this.discontinuityToTso_.get(discontintuitySequenceNum) || 0;
/** @type {!Array.<!shaka.media.SegmentReference>} */
const references = [];
const enumerate = (it) => shaka.util.Iterables.enumerate(it);
for (const {i, item} of enumerate(hlsSegments)) {
const previousReference = references[references.length - 1];
const startTime = (i == 0) ? firstStartTime :
previousReference.endTime;
position = mediaSequenceNumber + skippedSegments + i;
mediaSequenceToStartTime.set(position, startTime);
initSegmentRef = this.getInitSegmentReference_(playlist.absoluteUri,
item.tags, variables);
const discontintuityTag = shaka.hls.Utils.getFirstTagWithName(item.tags,
'EXT-X-DISCONTINUITY');
if (discontintuityTag) {
discontintuitySequenceNum++;
// eslint-disable-next-line no-await-in-loop
timestampOffset = await this.getTimestampOffset_(
discontintuitySequenceNum, verbatimMediaPlaylistUri, initSegmentRef,
mimeType, position, item, variables, startTime);
}
// If the stream is low latency and the user has not configured the
// lowLatencyMode, but if it has been configured to activate the
// lowLatencyMode if a stream of this type is detected, we automatically
// activate the lowLatencyMode.
if (!this.lowLatencyMode_) {
const autoLowLatencyMode = this.playerInterface_.isAutoLowLatencyMode();
if (autoLowLatencyMode) {
this.playerInterface_.enableLowLatencyMode();
this.lowLatencyMode_ = this.playerInterface_.isLowLatencyMode();
}
}
const extinfTag =
shaka.hls.Utils.getFirstTagWithName(item.tags, 'EXTINF');
if (this.lowLatencyMode_ || extinfTag) {
const reference = this.createSegmentReference_(
initSegmentRef,
previousReference,
item,
startTime,
timestampOffset,
variables,
playlist.absoluteUri);
references.push(reference);
} else if (!this.lowLatencyMode_) {
// If a segment has no extinfTag, it must contain partial segments.
shaka.log.alwaysWarn('Low-latency HLS live stream detected, but ' +
'low-latency streaming mode is not enabled in Shaka Player. ' +
'Set streaming.lowLatencyMode configuration to true, and see ' +
'https://bit.ly/3clctcj for details.');
}
}
this.segmentsToNotifyByStream_.push(references);
this.notifySegments_();
return references;
}
/**
* Gets the start time of the first segment of the playlist from existing
* value (if possible) or by downloading it and parsing it otherwise.
*
* @param {number} discontintuitySequenceNum
* @param {string} verbatimMediaPlaylistUri
* @param {shaka.media.InitSegmentReference} initSegmentRef
* @param {string} mimeType
* @param {number} mediaSequenceNumber
* @param {!shaka.hls.Segment} segment
* @param {!Map.<string, string>} variables
* @param {number} startTime
* @return {!Promise.<number>}
* @throws {shaka.util.Error}
* @private
*/
async getTimestampOffset_(discontintuitySequenceNum,
verbatimMediaPlaylistUri, initSegmentRef,
mimeType, mediaSequenceNumber, segment, variables, startTime) {
let timestampOffset = 0;
if (this.discontinuityToTso_.has(discontintuitySequenceNum)) {
timestampOffset =
this.discontinuityToTso_.get(discontintuitySequenceNum);
} else {
// eslint-disable-next-line no-await-in-loop
const mediaStartTime = await this.getStartTime_(
verbatimMediaPlaylistUri, initSegmentRef, mimeType,
mediaSequenceNumber, /* isDiscontinuity= */ true, segment,
variables);
timestampOffset = startTime - mediaStartTime;
shaka.log.v1('Segment timestampOffset =', timestampOffset);
this.discontinuityToTso_.set(
discontintuitySequenceNum, timestampOffset);
}
return timestampOffset;
}
/**
* Try to fetch the starting part of a segment, and fall back to a full
* segment if we have to.
*
* @param {!shaka.media.AnySegmentReference} reference
* @return {!Promise.<shaka.extern.Response>}
* @private
*/
async fetchStartOfSegment_(reference) {
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
// Create two requests:
// 1. A partial request meant to fetch the smallest part of the segment
// required to get the time stamp.
// 2. A full request meant as a fallback for when the server does not
// support partial requests.
const fullRequest = shaka.util.Networking.createSegmentRequest(
reference.getUris(),
reference.startByte,
reference.endByte,
this.config_.retryParameters);
if (this.config_.hls.useFullSegmentsForStartTime) {
return this.makeNetworkRequest_(fullRequest, requestType);
}
const partialRequest = shaka.util.Networking.createSegmentRequest(
reference.getUris(),
reference.startByte,
reference.startByte + shaka.hls.HlsParser.START_OF_SEGMENT_SIZE_ - 1,
this.config_.retryParameters);
// TODO(vaage): The need to do fall back requests is not likely to be unique
// to here. It would be nice if the fallback(s) could be included into
// the same abortable operation as the original request.
//
// What would need to change with networking engine to support requests
// with fallback(s)?
try {
const response = await this.makeNetworkRequest_(
partialRequest, requestType);
return response;
} catch (e) {
// If the networking operation was aborted, we don't want to treat it as
// a request failure. We surface the error so that the OPERATION_ABORTED
// error will be handled correctly.
if (e.code == shaka.util.Error.Code.OPERATION_ABORTED) {
throw e;
}
// The partial request may fail for a number of reasons.
// Some servers do not support Range requests, and others do not support
// the OPTIONS request which must be made before any cross-origin Range
// request. Since this fallback is expensive, warn the app developer.
shaka.log.alwaysWarn('Unable to fetch the starting part of HLS ' +
'segment! Falling back to a full segment request, ' +
'which is expensive! Your server should ' +
'support Range requests and CORS preflights.',
partialRequest.uris[0]);
const response = await this.makeNetworkRequest_(fullRequest, requestType);
return response;
}
}
/**
* Gets the start time of a segment from the existing manifest (if possible)
* or by downloading it and parsing it otherwise.
*
* @param {string} verbatimMediaPlaylistUri
* @param {shaka.media.InitSegmentReference} initSegmentRef
* @param {string} mimeType
* @param {number} mediaSequenceNumber
* @param {boolean} isDiscontinuity
* @param {!shaka.hls.Segment} segment
* @param {!Map.<string, string>} variables
* @return {!Promise.<number>}
* @private
*/
async getStartTime_(
verbatimMediaPlaylistUri, initSegmentRef, mimeType, mediaSequenceNumber,
isDiscontinuity, segment, variables) {
const segmentRef = this.createSegmentReference_(
initSegmentRef,
/* previousReference= */ null,
segment,
/* startTime= */ 0,
/* timestampOffset= */ 0,
variables,
/* absoluteMediaPlaylistUri= */ '');
// If we are updating the manifest, we can usually skip fetching the segment
// by examining the references we already have. This won't be possible if
// there was some kind of lag or delay updating the manifest on the server,
// in which extreme case we would fall back to fetching a segment. This
// allows us to both avoid fetching segments when possible, and recover from
// certain server-side issues gracefully.
// Do not use cached start time for the segments with discontinuity tags.
if (this.manifest_ && !isDiscontinuity) {
const streamInfo =
this.uriToStreamInfosMap_.get(verbatimMediaPlaylistUri);
const startTime = streamInfo.mediaSequenceToStartTime.get(
mediaSequenceNumber);
if (startTime != undefined) {
// We found it! Avoid fetching and parsing the segment.
shaka.log.v1('Found segment start time in previous manifest',
startTime);
return startTime;
}
shaka.log.debug(
'Unable to find segment start time in previous manifest!');
}
// TODO: Introduce a new tag to extend HLS and provide the first segment's
// start time. This will avoid the need for these fetches in content
// packaged with Shaka Packager. This web-friendly extension to HLS can
// then be proposed to Apple for inclusion in a future version of HLS.
// See https://github.com/google/shaka-packager/issues/294
shaka.log.v1('Fetching segment to find start time');
mimeType = mimeType.toLowerCase();
if (shaka.hls.HlsParser.RAW_FORMATS_.includes(mimeType)) {
// Raw formats contain no timestamps. Even if there is an ID3 tag with a
// timestamp, that's not going to be honored by MediaSource, which will
// use sequence mode for these segments. We don't yet support sequence
// mode, so we must reject these streams.
// TODO(#2337): Support sequence mode and align raw format timestamps to
// other streams.
shaka.log.alwaysWarn(
'Raw formats are not yet supported. Skipping ' + mimeType);
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM);
}
if (mimeType == 'video/webm') {
shaka.log.alwaysWarn('WebM in HLS is not yet supported. Skipping.');
throw new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM);
}
if (mimeType == 'video/mp4' || mimeType == 'audio/mp4') {
// We also need the init segment to get the correct timescale. But if the
// stream is self-initializing, use the same response for both.
const fetches = [this.fetchStartOfSegment_(segmentRef)];
if (initSegmentRef) {
fetches.push(this.fetchStartOfSegment_(initSegmentRef));
}
const responses = await Promise.all(fetches);
// If the stream is self-initializing, use the main segment in-place of
// the init segment.
const segmentResponse = responses[0];
const initSegmentResponse = responses[1] || responses[0];
return this.getStartTimeFromMp4Segment_(
verbatimMediaPlaylistUri, segmentResponse.uri,
segmentResponse.data, initSegmentResponse.data);
}
if (mimeType == 'video/mp2t') {
const response = await this.fetchStartOfSegment_(segmentRef);
goog.asserts.assert(response.data, 'Should have a response body!');
return this.getStartTimeFromTsSegment_(
verbatimMediaPlaylistUri, response.uri, response.data);
}
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME,
verbatimMediaPlaylistUri);
}
/**
* Parses an mp4 segment to get its start time.
*
* @param {string} playlistUri
* @param {string} segmentUri
* @param {BufferSource} mediaData
* @param {BufferSource} initData
* @return {number}
* @private
*/
getStartTimeFromMp4Segment_(playlistUri, segmentUri, mediaData, initData) {
const Mp4Parser = shaka.util.Mp4Parser;
let timescale = 0;
new Mp4Parser()
.box('moov', Mp4Parser.children)
.box('trak', Mp4Parser.children)
.box('mdia', Mp4Parser.children)
.fullBox('mdhd', (box) => {
goog.asserts.assert(
box.version == 0 || box.version == 1,
'MDHD version can only be 0 or 1');
const parsedMDHDBox = shaka.util.Mp4BoxParsers.parseMDHD(
box.reader, box.version);
timescale = parsedMDHDBox.timescale;
box.parser.stop();
}).parse(initData, /* partialOkay= */ true);
if (!timescale) {
shaka.log.error('Unable to find timescale in init segment!');
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME,
playlistUri, segmentUri);
}
let startTime = 0;
let parsedMedia = false;
new Mp4Parser()
.box('moof', Mp4Parser.children)
.box('traf', Mp4Parser.children)
.fullBox('tfdt', (box) => {
goog.asserts.assert(
box.version == 0 || box.version == 1,
'TFDT version can only be 0 or 1');
const parsedTFDTBox = shaka.util.Mp4BoxParsers.parseTFDT(
box.reader, box.version);
const baseTime = parsedTFDTBox.baseMediaDecodeTime;
startTime = baseTime / timescale;
parsedMedia = true;
box.parser.stop();
}).parse(mediaData, /* partialOkay= */ true);
if (!parsedMedia) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME,
playlistUri, segmentUri);
}
return startTime;
}
/**
* Parses a TS segment to get its start time.
*
* @param {string} playlistUri
* @param {string} segmentUri
* @param {BufferSource} data
* @return {number}
* @private
*/
getStartTimeFromTsSegment_(playlistUri, segmentUri, data) {
const reader = new shaka.util.DataViewReader(
data, shaka.util.DataViewReader.Endianness.BIG_ENDIAN);
const fail = () => {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME,
playlistUri, segmentUri);
};
let packetStart = 0;
let syncByte = 0;
const skipPacket = () => {
// 188-byte packets are standard, so assume that.
reader.seek(packetStart + 188);
syncByte = reader.readUint8();
if (syncByte != 0x47) {
// We haven't found the sync byte, so try it as a 192-byte packet.
reader.seek(packetStart + 192);
syncByte = reader.readUint8();
}
if (syncByte != 0x47) {
// We still haven't found the sync byte, so try as a 204-byte packet.
reader.seek(packetStart + 204);
syncByte = reader.readUint8();
}
if (syncByte != 0x47) {
// We still haven't found the sync byte, so the packet was of a
// non-standard size.
fail();
}
// Put the sync byte back so we can read it in the next loop.
reader.rewind(1);
};
// TODO: refactor this while loop for better readability.
// eslint-disable-next-line no-constant-condition
while (true) {
// Format reference: https://bit.ly/TsPacket
packetStart = reader.getPosition();
syncByte = reader.readUint8();
if (syncByte != 0x47) {
fail();
}
const flagsAndPacketId = reader.readUint16();
const packetId = flagsAndPacketId & 0x1fff;
if (packetId == 0x1fff) {
// A "null" TS packet. Skip this TS packet and try again.
skipPacket();
continue;
}
const hasPesPacket = flagsAndPacketId & 0x4000;
if (!hasPesPacket) {
// Not a PES packet yet. Skip this TS packet and try again.
skipPacket();
continue;
}
const flags = reader.readUint8();
const adaptationFieldControl = (flags & 0x30) >> 4;
if (adaptationFieldControl == 0 /* reserved */ ||
adaptationFieldControl == 2 /* adaptation field, no payload */) {
fail();
}
if (adaptationFieldControl == 3) {
// Skip over adaptation field.
const length = reader.readUint8();
reader.skip(length);
}
// Now we come to the PES header (hopefully).
// Format reference: https://bit.ly/TsPES
const startCode = reader.readUint32();
const startCodePrefix = startCode >> 8;
if (startCodePrefix != 1) {
// Not a PES packet yet. Skip this TS packet and try again.
skipPacket();
continue;
}
// Skip the 16-bit PES length and the first 8 bits of the optional header.
reader.skip(3);
// The next 8 bits contain flags about DTS & PTS.
const ptsDtsIndicator = reader.readUint8() >> 6;
if (ptsDtsIndicator == 0 /* no timestamp */ ||
ptsDtsIndicator == 1 /* forbidden */) {
fail();
}
const pesHeaderLengthRemaining = reader.readUint8();
if (pesHeaderLengthRemaining == 0) {
fail();
}
if (ptsDtsIndicator == 2 /* PTS only */) {
goog.asserts.assert(pesHeaderLengthRemaining == 5, 'Bad PES header?');
} else if (ptsDtsIndicator == 3 /* PTS and DTS */) {
goog.asserts.assert(pesHeaderLengthRemaining == 10, 'Bad PES header?');
}
const pts0 = reader.readUint8();
const pts1 = reader.readUint16();
const pts2 = reader.readUint16();
// Reconstruct 33-bit PTS from the 5-byte, padded structure.
const ptsHigh3 = (pts0 & 0x0e) >> 1;
const ptsLow30 = ((pts1 & 0xfffe) << 14) | ((pts2 & 0xfffe) >> 1);
// Reconstruct the PTS as a float. Avoid bitwise operations to combine
// because bitwise ops treat the values as 32-bit ints.
const pts = ptsHigh3 * (1 << 30) + ptsLow30;
return pts / shaka.hls.HlsParser.TS_TIMESCALE_;
}
}
/**
* Attempts to guess which codecs from the codecs list belong to a given
* content type.
* Assumes that at least one codec is correct, and throws if none are.
*
* @param {string} contentType
* @param {!Array.<string>} codecs
* @return {string}
* @private
*/
guessCodecs_(contentType, codecs) {
if (codecs.length == 1) {
return codecs[0];
}
const match = this.guessCodecsSafe_(contentType, codecs);
// A failure is specifically denoted by null; an empty string represents a
// valid match of no codec.
if (match != null) {
return match;
}
// Unable to guess codecs.
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_GUESS_CODECS,
codecs);
}
/**
* Attempts to guess which codecs from the codecs list belong to a given
* content type. Does not assume a single codec is anything special, and does
* not throw if it fails to match.
*
* @param {string} contentType
* @param {!Array.<string>} codecs
* @return {?string} or null if no match is found
* @private
*/
guessCodecsSafe_(contentType, codecs) {
const formats =
shaka.hls.HlsParser.CODEC_REGEXPS_BY_CONTENT_TYPE_[contentType];
for (const format of formats) {
for (const codec of codecs) {
if (format.test(codec.trim())) {
return codec.trim();
}
}
}
// Text does not require a codec string.
if (contentType == shaka.util.ManifestParserUtils.ContentType.TEXT) {
return '';
}
return null;
}
/**
* Replaces the variables of a given URI.
*
* @param {string} uri
* @param {!Map.<string, string>} variables
* @return {string}
* @private
*/
variableSubstitution_(uri, variables) {
let newUri = String(uri).replace(/%7B/g, '{').replace(/%7D/g, '}');
const uriVariables = newUri.match(/{\$\w*}/g);
if (uriVariables) {
for (const variable of uriVariables) {
// Note: All variables have the structure {$...}
const variableName = variable.slice(2, variable.length - 1);
const replaceValue = variables.get(variableName);
if (replaceValue) {
newUri = newUri.replace(variable, replaceValue);
} else {
shaka.log.error('A variable has been found that is not declared',
variableName);
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_VARIABLE_NOT_FOUND,
variableName);
}
}
}
return newUri;
}
/**
* Attempts to guess stream's mime type based on content type and URI.
*
* @param {string} contentType
* @param {string} codecs
* @param {!shaka.hls.Playlist} playlist
* @param {!Map.<string, string>} variables
* @return {!Promise.<string>}
* @private
*/
async guessMimeType_(contentType, codecs, playlist, variables) {
const HlsParser = shaka.hls.HlsParser;
const ContentType = shaka.util.ManifestParserUtils.ContentType;
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
goog.asserts.assert(playlist.segments.length,
'Playlist should have segments!');
const firstSegmentUri = this.variableSubstitution_(
playlist.segments[0].absoluteUri, variables);
const parsedUri = new goog.Uri(firstSegmentUri);
const extension = parsedUri.getPath().split('.').pop();
const map = HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_[contentType];
const mimeType = map[extension];
if (mimeType) {
return mimeType;
}
if (contentType == ContentType.TEXT) {
// The extension map didn't work.
if (!codecs || codecs == 'vtt' || codecs == 'wvtt') {
// If codecs is 'vtt', it's WebVTT.
// If there was no codecs string, assume HLS text streams are WebVTT.
return 'text/vtt';
} else {
// Otherwise, assume MP4-embedded text, since text-based formats tend
// not to have a codecs string at all.
return 'application/mp4';
}
}
// If unable to guess mime type, request a segment and try getting it
// from the response.
const headRequest = shaka.net.NetworkingEngine.makeRequest(
[firstSegmentUri], this.config_.retryParameters);
headRequest.method = 'HEAD';
const response = await this.makeNetworkRequest_(
headRequest, requestType);
const contentMimeType = response.headers['content-type'];
if (!contentMimeType) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_GUESS_MIME_TYPE,
extension);
}
// Split the MIME type in case the server sent additional parameters.
return contentMimeType.split(';')[0];
}
/**
* Returns a tag with a given name.
* Throws an error if tag was not found.
*
* @param {!Array.<shaka.hls.Tag>} tags
* @param {string} tagName
* @return {!shaka.hls.Tag}
* @private
*/
getRequiredTag_(tags, tagName) {
const tag = shaka.hls.Utils.getFirstTagWithName(tags, tagName);
if (!tag) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_REQUIRED_TAG_MISSING, tagName);
}
return tag;
}
/**
* @param {shaka.extern.Stream} stream
* @param {?string} width
* @param {?string} height
* @param {?string} frameRate
* @private
*/
addVideoAttributes_(stream, width, height, frameRate) {
if (stream) {
stream.width = Number(width) || undefined;
stream.height = Number(height) || undefined;
stream.frameRate = Number(frameRate) || undefined;
}
}
/**
* Makes a network request for the manifest and returns a Promise
* with the resulting data.
*
* @param {string} absoluteUri
* @return {!Promise.<!shaka.extern.Response>}
* @private
*/
requestManifest_(absoluteUri) {
const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST;
const request = shaka.net.NetworkingEngine.makeRequest(
[absoluteUri], this.config_.retryParameters);
return this.makeNetworkRequest_(request, requestType);
}
/**
* Called when the update timer ticks. Because parsing a manifest is async,
* this method is async. To work with this, this method will schedule the next
* update when it finished instead of using a repeating-start.
*
* @return {!Promise}
* @private
*/
async onUpdate_() {
shaka.log.info('Updating manifest...');
goog.asserts.assert(
this.updatePlaylistDelay_ > 0,
'We should only call |onUpdate_| when we are suppose to be updating.');
// Detect a call to stop()
if (!this.playerInterface_) {
return;
}
try {
await this.update();
const delay = this.updatePlaylistDelay_;
this.updatePlaylistTimer_.tickAfter(/* seconds= */ delay);
} catch (error) {
// Detect a call to stop() during this.update()
if (!this.playerInterface_) {
return;
}
goog.asserts.assert(error instanceof shaka.util.Error,
'Should only receive a Shaka error');
// We will retry updating, so override the severity of the error.
error.severity = shaka.util.Error.Severity.RECOVERABLE;
this.playerInterface_.onError(error);
// Try again very soon.
this.updatePlaylistTimer_.tickAfter(/* seconds= */ 0.1);
}
}
/**
* @return {boolean}
* @private
*/
isLive_() {
const PresentationType = shaka.hls.HlsParser.PresentationType_;
return this.presentationType_ != PresentationType.VOD;
}
/**
* @param {shaka.hls.HlsParser.PresentationType_} type
* @private
*/
setPresentationType_(type) {
this.presentationType_ = type;
if (this.presentationTimeline_) {
this.presentationTimeline_.setStatic(!this.isLive_());
}
// If this manifest is not for live content, then we have no reason to
// update it.
if (!this.isLive_()) {
this.updatePlaylistTimer_.stop();
}
}
/**
* Create a networking request. This will manage the request using the
* parser's operation manager. If the parser has already been stopped, the
* request will not be made.
*
* @param {shaka.extern.Request} request
* @param {shaka.net.NetworkingEngine.RequestType} type
* @return {!Promise.<shaka.extern.Response>}
* @private
*/
makeNetworkRequest_(request, type) {
if (!this.operationManager_) {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.PLAYER,
shaka.util.Error.Code.OPERATION_ABORTED);
}
const op = this.playerInterface_.networkingEngine.request(type, request);
this.operationManager_.manage(op);
return op.promise;
}
/**
* @param {!shaka.hls.Tag} drmTag
* @return {?shaka.extern.DrmInfo}
* @private
*/
static widevineDrmParser_(drmTag) {
const method = drmTag.getRequiredAttrValue('METHOD');
const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR'];
if (!VALID_METHODS.includes(method)) {
shaka.log.error('Widevine in HLS is only supported with [',
VALID_METHODS.join(', '), '], not', method);
return null;
}
const uri = drmTag.getRequiredAttrValue('URI');
const parsedData = shaka.net.DataUriPlugin.parseRaw(uri);
// The data encoded in the URI is a PSSH box to be used as init data.
const pssh = shaka.util.BufferUtils.toUint8(parsedData.data);
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
'com.widevine.alpha', [
{initDataType: 'cenc', initData: pssh},
]);
const keyId = drmTag.getAttributeValue('KEYID');
if (keyId) {
const keyIdLowerCase = keyId.toLowerCase();
// This value should begin with '0x':
goog.asserts.assert(
keyIdLowerCase.startsWith('0x'), 'Incorrect KEYID format!');
// But the output should not contain the '0x':
drmInfo.keyIds = new Set([keyIdLowerCase.substr(2)]);
}
return drmInfo;
}
/**
* See: https://docs.microsoft.com/en-us/playready/packaging/mp4-based-formats-supported-by-playready-clients?tabs=case4
*
* @param {!shaka.hls.Tag} drmTag
* @return {?shaka.extern.DrmInfo}
* @private
*/
static playreadyDrmParser_(drmTag) {
const method = drmTag.getRequiredAttrValue('METHOD');
const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR'];
if (!VALID_METHODS.includes(method)) {
shaka.log.error('PlayReady in HLS is only supported with [',
VALID_METHODS.join(', '), '], not', method);
return null;
}
const uri = drmTag.getRequiredAttrValue('URI');
const parsedData = shaka.net.DataUriPlugin.parseRaw(uri);
// The data encoded in the URI is a PlayReady Pro Object, so we need
// convert it to pssh.
const data = shaka.util.BufferUtils.toUint8(parsedData.data);
const systemId = new Uint8Array([
0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86,
0xab, 0x92, 0xe6, 0x5b, 0xe0, 0x88, 0x5f, 0x95,
]);
const pssh = shaka.util.Pssh.createPssh(data, systemId);
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
'com.microsoft.playready', [
{initDataType: 'cenc', initData: pssh},
]);
return drmInfo;
}
};
/**
* @typedef {{
* stream: !shaka.extern.Stream,
* verbatimMediaPlaylistUri: string,
* absoluteMediaPlaylistUri: string,
* minTimestamp: number,
* maxTimestamp: number,
* mediaSequenceToStartTime: !Map.<number, number>
* }}
*
* @description
* Contains a stream and information about it.
*
* @property {!shaka.extern.Stream} stream
* The Stream itself.
* @property {string} verbatimMediaPlaylistUri
* The verbatim media playlist URI, as it appeared in the master playlist.
* This has not been canonicalized into an absolute URI. This gives us a
* consistent key for this playlist, even if redirects cause us to update
* from different origins each time.
* @property {string} absoluteMediaPlaylistUri
* The absolute media playlist URI, resolved relative to the master playlist
* and updated to reflect any redirects.
* @property {number} minTimestamp
* The minimum timestamp found in the stream.
* @property {number} maxTimestamp
* The maximum timestamp found in the stream.
* @property {!Map.<number, number>} mediaSequenceToStartTime
* A map of media sequence numbers to media start times.
*/
shaka.hls.HlsParser.StreamInfo;
/**
* @typedef {{
* audio: !Array.<shaka.hls.HlsParser.StreamInfo>,
* video: !Array.<shaka.hls.HlsParser.StreamInfo>
* }}
*
* @description Audio and video stream infos.
* @property {!Array.<shaka.hls.HlsParser.StreamInfo>} audio
* @property {!Array.<shaka.hls.HlsParser.StreamInfo>} video
*/
shaka.hls.HlsParser.StreamInfos;
/**
* A list of regexps to detect well-known video codecs.
*
* @const {!Array.<!RegExp>}
* @private
*/
shaka.hls.HlsParser.VIDEO_CODEC_REGEXPS_ = [
/^avc/,
/^hev/,
/^hvc/,
/^vp0?[89]/,
/^av1$/,
];
/**
* A list of regexps to detect well-known audio codecs.
*
* @const {!Array.<!RegExp>}
* @private
*/
shaka.hls.HlsParser.AUDIO_CODEC_REGEXPS_ = [
/^vorbis$/,
/^opus$/,
/^flac$/,
/^mp4a/,
/^[ae]c-3$/,
];
/**
* A list of regexps to detect well-known text codecs.
*
* @const {!Array.<!RegExp>}
* @private
*/
shaka.hls.HlsParser.TEXT_CODEC_REGEXPS_ = [
/^vtt$/,
/^wvtt/,
/^stpp/,
];
/**
* @const {!Object.<string, !Array.<!RegExp>>}
* @private
*/
shaka.hls.HlsParser.CODEC_REGEXPS_BY_CONTENT_TYPE_ = {
'audio': shaka.hls.HlsParser.AUDIO_CODEC_REGEXPS_,
'video': shaka.hls.HlsParser.VIDEO_CODEC_REGEXPS_,
'text': shaka.hls.HlsParser.TEXT_CODEC_REGEXPS_,
};
/**
* @const {!Object.<string, string>}
* @private
*/
shaka.hls.HlsParser.AUDIO_EXTENSIONS_TO_MIME_TYPES_ = {
'mp4': 'audio/mp4',
'mp4a': 'audio/mp4',
'm4s': 'audio/mp4',
'm4i': 'audio/mp4',
'm4a': 'audio/mp4',
'cmfa': 'audio/mp4',
// MPEG2-TS also uses video/ for audio: https://bit.ly/TsMse
'ts': 'video/mp2t',
// Raw formats:
'aac': 'audio/aac',
'ac3': 'audio/ac3',
'ec3': 'audio/ec3',
'mp3': 'audio/mpeg',
};
/**
* MIME types of raw formats.
* TODO(#2337): Support raw formats and share this list among parsers.
*
* @const {!Array.<string>}
* @private
*/
shaka.hls.HlsParser.RAW_FORMATS_ = [
'audio/aac',
'audio/ac3',
'audio/ec3',
'audio/mpeg',
];
/**
* @const {!Object.<string, string>}
* @private
*/
shaka.hls.HlsParser.VIDEO_EXTENSIONS_TO_MIME_TYPES_ = {
'mp4': 'video/mp4',
'mp4v': 'video/mp4',
'm4s': 'video/mp4',
'm4i': 'video/mp4',
'm4v': 'video/mp4',
'cmfv': 'video/mp4',
'ts': 'video/mp2t',
};
/**
* @const {!Object.<string, string>}
* @private
*/
shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_ = {
'mp4': 'application/mp4',
'm4s': 'application/mp4',
'm4i': 'application/mp4',
'cmft': 'application/mp4',
'vtt': 'text/vtt',
'ttml': 'application/ttml+xml',
};
/**
* @const {!Object.<string, !Object.<string, string>>}
* @private
*/
shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_ = {
'audio': shaka.hls.HlsParser.AUDIO_EXTENSIONS_TO_MIME_TYPES_,
'video': shaka.hls.HlsParser.VIDEO_EXTENSIONS_TO_MIME_TYPES_,
'text': shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_,
};
/**
* @typedef {function(!shaka.hls.Tag):?shaka.extern.DrmInfo}
* @private
*/
shaka.hls.HlsParser.DrmParser_;
/**
* @const {!Object.<string, shaka.hls.HlsParser.DrmParser_>}
* @private
*/
shaka.hls.HlsParser.KEYFORMATS_TO_DRM_PARSERS_ = {
/* TODO: https://github.com/google/shaka-player/issues/382
'com.apple.streamingkeydelivery':
shaka.hls.HlsParser.fairplayDrmParser_,
*/
'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed':
shaka.hls.HlsParser.widevineDrmParser_,
'com.microsoft.playready':
shaka.hls.HlsParser.playreadyDrmParser_,
};
/**
* @enum {string}
* @private
*/
shaka.hls.HlsParser.PresentationType_ = {
VOD: 'VOD',
EVENT: 'EVENT',
LIVE: 'LIVE',
};
/**
* @const {number}
* @private
*/
shaka.hls.HlsParser.TS_TIMESCALE_ = 90000;
/**
* The amount of data from the start of a segment we will try to fetch when we
* need to know the segment start time. This allows us to avoid fetching the
* entire segment in many cases.
*
* @const {number}
* @private
*/
shaka.hls.HlsParser.START_OF_SEGMENT_SIZE_ = 2048;
shaka.media.ManifestParser.registerParserByExtension(
'm3u8', () => new shaka.hls.HlsParser());
shaka.media.ManifestParser.registerParserByMime(
'application/x-mpegurl', () => new shaka.hls.HlsParser());
shaka.media.ManifestParser.registerParserByMime(
'application/vnd.apple.mpegurl', () => new shaka.hls.HlsParser());