mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-26 17:46:26 +03:00
a384e0c02d
Previously, AVERAGE-BANDWIDTH was prioritized over BANDWIDTH, but this produces undesirable results when peaks are reproduced and the ABR is unable to act.
5624 lines
193 KiB
JavaScript
5624 lines
193 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.abr.Ewma');
|
|
goog.require('shaka.drm.DrmUtils');
|
|
goog.require('shaka.drm.FairPlay');
|
|
goog.require('shaka.drm.PlayReady');
|
|
goog.require('shaka.hls.Attribute');
|
|
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.InitSegmentReference');
|
|
goog.require('shaka.media.ManifestParser');
|
|
goog.require('shaka.media.PresentationTimeline');
|
|
goog.require('shaka.media.QualityObserver');
|
|
goog.require('shaka.media.SegmentIndex');
|
|
goog.require('shaka.media.SegmentReference');
|
|
goog.require('shaka.media.SegmentUtils');
|
|
goog.require('shaka.net.DataUriPlugin');
|
|
goog.require('shaka.net.NetworkingEngine');
|
|
goog.require('shaka.net.NetworkingEngine.PendingRequest');
|
|
goog.require('shaka.net.NetworkingUtils');
|
|
goog.require('shaka.util.ArrayUtils');
|
|
goog.require('shaka.util.BufferUtils');
|
|
goog.require('shaka.util.ContentSteeringManager');
|
|
goog.require('shaka.util.Error');
|
|
goog.require('shaka.util.EventManager');
|
|
goog.require('shaka.util.FakeEvent');
|
|
goog.require('shaka.util.LanguageUtils');
|
|
goog.require('shaka.util.ManifestParserUtils');
|
|
goog.require('shaka.util.MimeUtils');
|
|
goog.require('shaka.util.Networking');
|
|
goog.require('shaka.util.OperationManager');
|
|
goog.require('shaka.util.Pssh');
|
|
goog.require('shaka.util.StreamUtils');
|
|
goog.require('shaka.util.StringUtils');
|
|
goog.require('shaka.util.Timer');
|
|
goog.require('shaka.util.TsParser');
|
|
goog.require('shaka.util.TXml');
|
|
goog.require('shaka.util.Uint8ArrayUtils');
|
|
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();
|
|
|
|
/**
|
|
* For media playlist lazy-loading to work in livestreams, we have to assume
|
|
* that each stream of a type (video, audio, etc) has the same mappings of
|
|
* sequence number to start time.
|
|
* This map stores those relationships.
|
|
* Only used during livestreams; we do not assume that VOD content is
|
|
* aligned in that way.
|
|
* @private {!Map<string, !Map<number, number>>}
|
|
*/
|
|
this.mediaSequenceToStartTimeByType_ = new Map();
|
|
|
|
// Set initial maps.
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
this.mediaSequenceToStartTimeByType_.set(ContentType.VIDEO, new Map());
|
|
this.mediaSequenceToStartTimeByType_.set(ContentType.AUDIO, new Map());
|
|
this.mediaSequenceToStartTimeByType_.set(ContentType.TEXT, new Map());
|
|
this.mediaSequenceToStartTimeByType_.set(ContentType.IMAGE, new Map());
|
|
|
|
/** @private {!Map<string, shaka.hls.HlsParser.DrmParser_>} */
|
|
this.keyFormatsToDrmParsers_ = new Map()
|
|
.set('com.apple.streamingkeydelivery',
|
|
(tag, type, ref) => this.fairplayDrmParser_(tag, type, ref))
|
|
.set('urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed',
|
|
(tag, type, ref) => this.widevineDrmParser_(tag, type, ref))
|
|
.set('com.microsoft.playready',
|
|
(tag, type, ref) => this.playreadyDrmParser_(tag, type, ref))
|
|
.set('urn:uuid:3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c',
|
|
(tag, type, ref) => this.wiseplayDrmParser_(tag, type, ref));
|
|
|
|
/**
|
|
* 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
|
|
* createStreamInfoFromMediaTags_, createStreamInfoFromImageTag_ and
|
|
* createStreamInfoFromVariantTags_.
|
|
*
|
|
* @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();
|
|
|
|
/**
|
|
* The minimum sequence number for generated segments, when ignoring
|
|
* EXT-X-PROGRAM-DATE-TIME.
|
|
*
|
|
* @private {number}
|
|
*/
|
|
this.minSequenceNumber_ = -1;
|
|
|
|
/**
|
|
* The lowest time value for any of the streams, as defined by the
|
|
* EXT-X-PROGRAM-DATE-TIME value. Measured in seconds since January 1, 1970.
|
|
*
|
|
* @private {number}
|
|
*/
|
|
this.lowestSyncTime_ = Infinity;
|
|
|
|
/**
|
|
* Flag to indicate if any of the media playlists use
|
|
* EXT-X-PROGRAM-DATE-TIME.
|
|
*
|
|
* @private {boolean}
|
|
*/
|
|
this.usesProgramDateTime_ = false;
|
|
|
|
/**
|
|
* Whether the streams have previously been "finalized"; that is to say,
|
|
* whether we have loaded enough streams to know information about the asset
|
|
* such as timing information, live status, etc.
|
|
*
|
|
* @private {boolean}
|
|
*/
|
|
this.streamsFinalized_ = false;
|
|
|
|
/**
|
|
* Whether the manifest informs about the codec to use.
|
|
*
|
|
* @private
|
|
*/
|
|
this.codecInfoInManifest_ = false;
|
|
|
|
/**
|
|
* 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(() => {
|
|
if (this.mediaElement_ && !this.config_.continueLoadingWhenPaused) {
|
|
this.eventManager_.unlisten(this.mediaElement_, 'timeupdate');
|
|
if (this.mediaElement_.paused) {
|
|
this.eventManager_.listenOnce(
|
|
this.mediaElement_, 'timeupdate', () => this.onUpdate_());
|
|
return;
|
|
}
|
|
}
|
|
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.lastTargetDuration_ = Infinity;
|
|
|
|
/**
|
|
* Partial segments target duration.
|
|
* @private {number}
|
|
*/
|
|
this.partialTargetDuration_ = 0;
|
|
|
|
/** @private {number} */
|
|
this.presentationDelay_ = 0;
|
|
|
|
/** @private {number} */
|
|
this.lowLatencyPresentationDelay_ = 0;
|
|
|
|
/** @private {shaka.util.OperationManager} */
|
|
this.operationManager_ = new shaka.util.OperationManager();
|
|
|
|
/**
|
|
* 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();
|
|
|
|
/** @private {Map<string, string>} */
|
|
this.groupIdToCodecsMap_ = new Map();
|
|
|
|
/**
|
|
* 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.
|
|
* @private {!Map<string, !shaka.media.InitSegmentReference>}
|
|
*/
|
|
this.mapTagToInitSegmentRefMap_ = new Map();
|
|
|
|
/** @private {Map<string, !shaka.extern.aesKey>} */
|
|
this.aesKeyInfoMap_ = new Map();
|
|
|
|
/** @private {Map<string, !Promise<shaka.extern.Response>>} */
|
|
this.aesKeyMap_ = new Map();
|
|
|
|
/** @private {Map<string, !Promise<shaka.extern.Response>>} */
|
|
this.identityKeyMap_ = new Map();
|
|
|
|
/** @private {Map<!shaka.media.InitSegmentReference, ?string>} */
|
|
this.initSegmentToKidMap_ = new Map();
|
|
|
|
/** @private {boolean} */
|
|
this.lowLatencyMode_ = false;
|
|
|
|
/** @private {boolean} */
|
|
this.lowLatencyByterangeOptimization_ = false;
|
|
|
|
/**
|
|
* An ewma that tracks how long updates take.
|
|
* This is to mitigate issues caused by slow parsing on embedded devices.
|
|
* @private {!shaka.abr.Ewma}
|
|
*/
|
|
this.averageUpdateDuration_ = new shaka.abr.Ewma(5);
|
|
|
|
/** @private {?shaka.util.ContentSteeringManager} */
|
|
this.contentSteeringManager_ = null;
|
|
|
|
/** @private {boolean} */
|
|
this.needsClosedCaptionsDetection_ = true;
|
|
|
|
/** @private {shaka.util.EventManager} */
|
|
this.eventManager_ = new shaka.util.EventManager();
|
|
|
|
/** @private {HTMLMediaElement} */
|
|
this.mediaElement_ = null;
|
|
|
|
/** @private {?number} */
|
|
this.startTime_ = null;
|
|
|
|
/** @private {function():boolean} */
|
|
this.isPreloadFn_ = () => false;
|
|
|
|
/** @private {!Map<string, !Uint8Array>} */
|
|
this.psshToInitData_ = new Map();
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {shaka.extern.ManifestConfiguration} config
|
|
* @param {(function():boolean)=} isPreloadFn
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
configure(config, isPreloadFn) {
|
|
const needFireUpdate = this.playerInterface_ &&
|
|
config.updatePeriod != this.config_.updatePeriod &&
|
|
config.updatePeriod >= 0;
|
|
this.config_ = config;
|
|
if (isPreloadFn) {
|
|
this.isPreloadFn_ = isPreloadFn;
|
|
}
|
|
|
|
if (this.contentSteeringManager_) {
|
|
this.contentSteeringManager_.configure(this.config_);
|
|
}
|
|
|
|
if (needFireUpdate && this.manifest_ &&
|
|
this.manifest_.presentationTimeline.isLive()) {
|
|
this.updatePlaylistTimer_.tickNow();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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]).promise;
|
|
|
|
// 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);
|
|
|
|
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();
|
|
this.mapTagToInitSegmentRefMap_.clear();
|
|
this.aesKeyInfoMap_.clear();
|
|
this.aesKeyMap_.clear();
|
|
this.identityKeyMap_.clear();
|
|
this.initSegmentToKidMap_.clear();
|
|
this.psshToInitData_.clear();
|
|
|
|
if (this.contentSteeringManager_) {
|
|
this.contentSteeringManager_.destroy();
|
|
}
|
|
|
|
if (this.eventManager_) {
|
|
this.eventManager_.release();
|
|
this.eventManager_ = null;
|
|
}
|
|
|
|
return Promise.all(pending);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
async update() {
|
|
if (!this.isLive_()) {
|
|
return;
|
|
}
|
|
|
|
/** @type {!Array<!Promise>} */
|
|
const updates = [];
|
|
const streamInfos = Array.from(this.uriToStreamInfosMap_.values());
|
|
|
|
// This is necessary to calculate correctly the update time.
|
|
this.lastTargetDuration_ = Infinity;
|
|
this.manifest_.gapCount = 0;
|
|
|
|
// Only update active streams.
|
|
const activeStreamInfos = streamInfos.filter(
|
|
(s) => !s.stream.isAudioMuxedInVideo && s.stream.segmentIndex);
|
|
for (const streamInfo of activeStreamInfos) {
|
|
updates.push(this.updateStream_(streamInfo));
|
|
}
|
|
await Promise.all(updates);
|
|
|
|
// Now that streams have been updated, notify the presentation timeline.
|
|
this.notifySegmentsForStreams_(activeStreamInfos.map((s) => s.stream));
|
|
|
|
// If any hasEndList is false, the stream is still live.
|
|
const stillLive = activeStreamInfos.some((s) => s.hasEndList == false);
|
|
if (activeStreamInfos.length && !stillLive) {
|
|
// Convert the presentation to VOD and set the duration.
|
|
this.setPresentationType_(shaka.hls.HlsParser.PresentationType_.VOD);
|
|
this.determineDuration_();
|
|
}
|
|
if (stillLive) {
|
|
this.determineDuration_();
|
|
}
|
|
// Check if any playlist does not have the first reference (due to a
|
|
// problem in the live encoder for example), and disable the stream if
|
|
// necessary.
|
|
for (const streamInfo of activeStreamInfos) {
|
|
if (!streamInfo.stream.isAudioMuxedInVideo &&
|
|
streamInfo.stream.segmentIndex &&
|
|
!streamInfo.stream.segmentIndex.earliestReference()) {
|
|
this.playerInterface_.disableStream(streamInfo.stream);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.HlsParser.StreamInfo} streamInfo
|
|
* @return {!Map<number, number>}
|
|
* @private
|
|
*/
|
|
getMediaSequenceToStartTimeFor_(streamInfo) {
|
|
if (this.isLive_()) {
|
|
return this.mediaSequenceToStartTimeByType_.get(streamInfo.type);
|
|
} else {
|
|
return streamInfo.mediaSequenceToStartTime;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a stream.
|
|
*
|
|
* @param {!shaka.hls.HlsParser.StreamInfo} streamInfo
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async updateStream_(streamInfo) {
|
|
if (streamInfo.stream.isAudioMuxedInVideo) {
|
|
return;
|
|
}
|
|
const manifestUris = [];
|
|
for (const uri of streamInfo.getUris()) {
|
|
const uriObj = new goog.Uri(uri);
|
|
const queryData = uriObj.getQueryData();
|
|
if (streamInfo.canBlockReload) {
|
|
if (streamInfo.nextMediaSequence >= 0) {
|
|
// Indicates that the server must hold the request until a Playlist
|
|
// contains a Media Segment with Media Sequence
|
|
queryData.add('_HLS_msn', String(streamInfo.nextMediaSequence));
|
|
}
|
|
if (streamInfo.nextPart >= 0) {
|
|
// Indicates, in combination with _HLS_msn, that the server must hold
|
|
// the request until a Playlist contains Partial Segment N of Media
|
|
// Sequence Number M or later.
|
|
queryData.add('_HLS_part', String(streamInfo.nextPart));
|
|
}
|
|
}
|
|
if (streamInfo.canSkipSegments) {
|
|
// Enable delta updates. This will replace older segments with
|
|
// 'EXT-X-SKIP' tag in the media playlist.
|
|
queryData.add('_HLS_skip', 'YES');
|
|
}
|
|
if (queryData.getCount()) {
|
|
uriObj.setQueryData(queryData.toDecodedString());
|
|
}
|
|
manifestUris.push(uriObj.toString());
|
|
}
|
|
let response;
|
|
try {
|
|
response = await this.requestManifest_(
|
|
manifestUris, /* isPlaylist= */ true).promise;
|
|
} catch (e) {
|
|
if (this.playerInterface_) {
|
|
this.playerInterface_.disableStream(streamInfo.stream);
|
|
}
|
|
throw e;
|
|
}
|
|
if (!streamInfo.stream.segmentIndex) {
|
|
// The stream was closed since the update was first requested.
|
|
return;
|
|
}
|
|
|
|
/** @type {shaka.hls.Playlist} */
|
|
const playlist = this.manifestTextParser_.parsePlaylist(response.data);
|
|
|
|
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);
|
|
}
|
|
|
|
// Record the final URI after redirects.
|
|
const responseUri = response.uri;
|
|
if (responseUri != response.originalUri &&
|
|
!streamInfo.getUris().includes(responseUri)) {
|
|
streamInfo.redirectUris.push(responseUri);
|
|
}
|
|
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags,
|
|
'EXT-X-DEFINE');
|
|
|
|
const mediaVariables = this.parseMediaVariables_(
|
|
variablesTags, responseUri);
|
|
|
|
const stream = streamInfo.stream;
|
|
|
|
const mediaSequenceToStartTime =
|
|
this.getMediaSequenceToStartTimeFor_(streamInfo);
|
|
const {keyIds, drmInfos, encrypted, aesEncrypted} =
|
|
await this.parseDrmInfo_(playlist, stream.mimeType,
|
|
streamInfo.getUris, mediaVariables);
|
|
|
|
if (!stream.encrypted && encrypted && !aesEncrypted) {
|
|
stream.encrypted = true;
|
|
}
|
|
|
|
const keysAreEqual =
|
|
(a, b) => a.size === b.size && [...a].every((value) => b.has(value));
|
|
|
|
if (!keysAreEqual(stream.keyIds, keyIds)) {
|
|
stream.keyIds = keyIds;
|
|
stream.drmInfos = drmInfos;
|
|
this.playerInterface_.newDrmInfo(stream);
|
|
}
|
|
|
|
const {segments, bandwidth} = this.createSegments_(
|
|
playlist, mediaSequenceToStartTime, mediaVariables,
|
|
streamInfo.getUris, streamInfo.type);
|
|
if (bandwidth) {
|
|
stream.bandwidth = bandwidth;
|
|
}
|
|
|
|
const qualityInfo =
|
|
shaka.media.QualityObserver.createQualityInfo(stream);
|
|
for (const segment of segments) {
|
|
if (segment.initSegmentReference) {
|
|
segment.initSegmentReference.mediaQuality = qualityInfo;
|
|
}
|
|
}
|
|
|
|
stream.segmentIndex.mergeAndEvict(
|
|
segments, this.presentationTimeline_.getSegmentAvailabilityStart());
|
|
if (segments.length) {
|
|
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;
|
|
const {nextMediaSequence, nextPart} =
|
|
this.getNextMediaSequenceAndPart_(mediaSequenceNumber, segments);
|
|
streamInfo.nextMediaSequence = nextMediaSequence + skippedSegments;
|
|
streamInfo.nextPart = nextPart;
|
|
const playlistStartTime = mediaSequenceToStartTime.get(
|
|
mediaSequenceNumber);
|
|
stream.segmentIndex.evict(playlistStartTime);
|
|
}
|
|
const oldSegment = stream.segmentIndex.earliestReference();
|
|
if (oldSegment) {
|
|
streamInfo.minTimestamp = oldSegment.startTime;
|
|
|
|
const newestSegment = segments[segments.length - 1];
|
|
goog.asserts.assert(newestSegment, 'Should have segments!');
|
|
|
|
streamInfo.maxTimestamp = newestSegment.endTime;
|
|
}
|
|
|
|
// 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) {
|
|
// Flag this for later. We don't convert the whole presentation into VOD
|
|
// until we've seen the ENDLIST tag for all active playlists.
|
|
streamInfo.hasEndList = true;
|
|
}
|
|
|
|
this.determineLastTargetDuration_(playlist);
|
|
|
|
this.processDateRangeTags_(
|
|
playlist.tags, stream.type, mediaVariables, streamInfo.getUris);
|
|
}
|
|
|
|
|
|
/**
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
onExpirationUpdated(sessionId, expiration) {
|
|
// No-op
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
onInitialVariantChosen(variant) {
|
|
// No-op
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
banLocation(uri) {
|
|
if (this.contentSteeringManager_) {
|
|
this.contentSteeringManager_.banLocation(uri);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @exportInterface
|
|
*/
|
|
setMediaElement(mediaElement) {
|
|
this.mediaElement_ = mediaElement;
|
|
}
|
|
|
|
/**
|
|
* Align the streams by sequence number by dropping early segments. Then
|
|
* offset the streams to begin at presentation time 0.
|
|
* @param {!Array<!shaka.hls.HlsParser.StreamInfo>} streamInfos
|
|
* @param {boolean=} force
|
|
* @private
|
|
*/
|
|
syncStreamsWithSequenceNumber_(streamInfos, force = false) {
|
|
// We assume that, when this is first called, we have enough info to
|
|
// determine how to use the program date times (e.g. we have both a video
|
|
// and an audio, and all other videos and audios match those).
|
|
// Thus, we only need to calculate this once.
|
|
const updateMinSequenceNumber = this.minSequenceNumber_ == -1;
|
|
// Sync using media sequence number. Find the highest starting sequence
|
|
// number among all streams. Later, we will drop any references to
|
|
// earlier segments in other streams, then offset everything back to 0.
|
|
for (const streamInfo of streamInfos) {
|
|
const segmentIndex = streamInfo.stream.segmentIndex;
|
|
goog.asserts.assert(segmentIndex,
|
|
'Only loaded streams should be synced');
|
|
const mediaSequenceToStartTime =
|
|
this.getMediaSequenceToStartTimeFor_(streamInfo);
|
|
const segment0 = segmentIndex.earliestReference();
|
|
if (segment0) {
|
|
// This looks inefficient, but iteration order is insertion order.
|
|
// So the very first entry should be the one we want.
|
|
// We assert that this holds true so that we are alerted by debug
|
|
// builds and tests if it changes. We still do a loop, though, so
|
|
// that the code functions correctly in production no matter what.
|
|
if (goog.DEBUG) {
|
|
const firstSequenceStartTime =
|
|
mediaSequenceToStartTime.values().next().value;
|
|
if (firstSequenceStartTime != segment0.startTime) {
|
|
shaka.log.warning(
|
|
'Sequence number map is not ordered as expected!');
|
|
}
|
|
}
|
|
for (const [sequence, start] of mediaSequenceToStartTime) {
|
|
if (start == segment0.startTime) {
|
|
if (updateMinSequenceNumber) {
|
|
this.minSequenceNumber_ = Math.max(
|
|
this.minSequenceNumber_, sequence);
|
|
}
|
|
// Even if we already have decided on a value for
|
|
// |this.minSequenceNumber_|, we still need to determine the first
|
|
// sequence number for the stream, to offset it in the code below.
|
|
streamInfo.firstSequenceNumber = sequence;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.minSequenceNumber_ < 0) {
|
|
// Nothing to sync.
|
|
return;
|
|
}
|
|
|
|
shaka.log.debug('Syncing HLS streams against base sequence number:',
|
|
this.minSequenceNumber_);
|
|
|
|
for (const streamInfo of streamInfos) {
|
|
if (!this.ignoreManifestProgramDateTimeFor_(streamInfo.type) && !force) {
|
|
continue;
|
|
}
|
|
const segmentIndex = streamInfo.stream.segmentIndex;
|
|
if (segmentIndex) {
|
|
// Drop any earlier references.
|
|
const numSegmentsToDrop =
|
|
this.minSequenceNumber_ - streamInfo.firstSequenceNumber;
|
|
if (numSegmentsToDrop > 0) {
|
|
segmentIndex.dropFirstReferences(numSegmentsToDrop);
|
|
|
|
// Now adjust timestamps back to begin at 0.
|
|
const segmentN = segmentIndex.earliestReference();
|
|
if (segmentN) {
|
|
const streamOffset = -segmentN.startTime;
|
|
// Modify all SegmentReferences equally.
|
|
streamInfo.stream.segmentIndex.offset(streamOffset);
|
|
// Update other parts of streamInfo the same way.
|
|
this.offsetStreamInfo_(streamInfo, streamOffset);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronize streams by the EXT-X-PROGRAM-DATE-TIME tags attached to their
|
|
* segments. Also normalizes segment times so that the earliest segment in
|
|
* any stream is at time 0.
|
|
* @param {!Array<!shaka.hls.HlsParser.StreamInfo>} streamInfos
|
|
* @private
|
|
*/
|
|
syncStreamsWithProgramDateTime_(streamInfos) {
|
|
// We assume that, when this is first called, we have enough info to
|
|
// determine how to use the program date times (e.g. we have both a video
|
|
// and an audio, and all other videos and audios match those).
|
|
// Thus, we only need to calculate this once.
|
|
if (this.lowestSyncTime_ == Infinity) {
|
|
for (const streamInfo of streamInfos) {
|
|
const segmentIndex = streamInfo.stream.segmentIndex;
|
|
goog.asserts.assert(segmentIndex,
|
|
'Only loaded streams should be synced');
|
|
const segment0 = segmentIndex.earliestReference();
|
|
if (segment0 != null && segment0.syncTime != null) {
|
|
this.lowestSyncTime_ =
|
|
Math.min(this.lowestSyncTime_, segment0.syncTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
const lowestSyncTime = this.lowestSyncTime_;
|
|
if (lowestSyncTime == Infinity) {
|
|
// Nothing to sync.
|
|
return;
|
|
}
|
|
|
|
shaka.log.debug('Syncing HLS streams against base time:', lowestSyncTime);
|
|
|
|
for (const streamInfo of this.uriToStreamInfosMap_.values()) {
|
|
if (this.ignoreManifestProgramDateTimeFor_(streamInfo.type)) {
|
|
continue;
|
|
}
|
|
const segmentIndex = streamInfo.stream.segmentIndex;
|
|
if (segmentIndex != null) {
|
|
// A segment's startTime should be based on its syncTime vs the lowest
|
|
// syncTime across all streams. The earliest segment sync time from
|
|
// any stream will become presentation time 0. If two streams start
|
|
// e.g. 6 seconds apart in syncTime, then their first segments will
|
|
// also start 6 seconds apart in presentation time.
|
|
|
|
const segment0 = segmentIndex.earliestReference();
|
|
if (!segment0) {
|
|
continue;
|
|
}
|
|
if (segment0.syncTime == null) {
|
|
shaka.log.alwaysError('Missing EXT-X-PROGRAM-DATE-TIME for stream',
|
|
streamInfo.getUris(),
|
|
'Expect AV sync issues!');
|
|
} else {
|
|
// Stream metadata are offset by a fixed amount based on the
|
|
// first segment.
|
|
const segment0TargetTime = segment0.syncTime - lowestSyncTime;
|
|
const streamOffset = segment0TargetTime - segment0.startTime;
|
|
this.offsetStreamInfo_(streamInfo, streamOffset);
|
|
|
|
// This is computed across all segments separately to manage
|
|
// accumulated drift in durations.
|
|
for (const segment of segmentIndex) {
|
|
segment.syncAgainst(lowestSyncTime);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.HlsParser.StreamInfo} streamInfo
|
|
* @param {number} offset
|
|
* @private
|
|
*/
|
|
offsetStreamInfo_(streamInfo, offset) {
|
|
// Due to float compute issue we can have some millisecond issue.
|
|
// We don't apply the offset if it's the case.
|
|
if (Math.abs(offset) < 0.001) {
|
|
return;
|
|
}
|
|
// Adjust our accounting of the minimum timestamp.
|
|
streamInfo.minTimestamp += offset;
|
|
|
|
// Adjust our accounting of the maximum timestamp.
|
|
streamInfo.maxTimestamp += offset;
|
|
goog.asserts.assert(streamInfo.maxTimestamp >= 0,
|
|
'Negative maxTimestamp after adjustment!');
|
|
|
|
// Update our map from sequence number to start time.
|
|
const mediaSequenceToStartTime =
|
|
this.getMediaSequenceToStartTimeFor_(streamInfo);
|
|
for (const [key, value] of mediaSequenceToStartTime) {
|
|
mediaSequenceToStartTime.set(key, value + offset);
|
|
}
|
|
|
|
shaka.log.debug('Offset', offset, 'applied to',
|
|
streamInfo.getUris());
|
|
}
|
|
|
|
/**
|
|
* Parses the manifest.
|
|
*
|
|
* @param {BufferSource} data
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async parseManifest_(data) {
|
|
const Utils = shaka.hls.Utils;
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
goog.asserts.assert(this.masterPlaylistUri_,
|
|
'Master playlist URI must be set before calling parseManifest_!');
|
|
|
|
const playlist = this.manifestTextParser_.parsePlaylist(data);
|
|
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const variablesTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-DEFINE');
|
|
|
|
/** @type {!Array<!shaka.extern.Variant>} */
|
|
let variants = [];
|
|
/** @type {!Array<!shaka.extern.Stream>} */
|
|
let textStreams = [];
|
|
/** @type {!Array<!shaka.extern.Stream>} */
|
|
let imageStreams = [];
|
|
/** @type {!Array<!shaka.extern.Stream>} */
|
|
let chapterStreams = [];
|
|
|
|
// This assert is our own sanity check.
|
|
goog.asserts.assert(this.presentationTimeline_ == null,
|
|
'Presentation timeline created early!');
|
|
// We don't know if the presentation is VOD or live until we parse at least
|
|
// one media playlist, so make a VOD-style presentation timeline for now
|
|
// and change the type later if we discover this is live.
|
|
// Since the player will load the first variant chosen early in the process,
|
|
// there isn't a window during playback where the live-ness is unknown.
|
|
this.presentationTimeline_ = new shaka.media.PresentationTimeline(
|
|
/* presentationStartTime= */ null, /* delay= */ 0);
|
|
this.presentationTimeline_.setStatic(true);
|
|
|
|
const getUris = () => {
|
|
return [this.masterPlaylistUri_];
|
|
};
|
|
|
|
/** @type {?string} */
|
|
let mediaPlaylistType = null;
|
|
|
|
/** @type {!Map<string, string>} */
|
|
let mediaVariables = new Map();
|
|
|
|
// Parsing a media playlist results in a single-variant stream.
|
|
if (playlist.type == shaka.hls.PlaylistType.MEDIA) {
|
|
this.needsClosedCaptionsDetection_ = false;
|
|
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const variablesTags = shaka.hls.Utils.filterTagsByName(playlist.tags,
|
|
'EXT-X-DEFINE');
|
|
|
|
mediaVariables = this.parseMediaVariables_(
|
|
variablesTags, this.masterPlaylistUri_);
|
|
|
|
// By default we assume it is video, but in a later step the correct type
|
|
// is obtained.
|
|
mediaPlaylistType = ContentType.VIDEO;
|
|
|
|
// These values can be obtained later so these default values are good.
|
|
const codecs = '';
|
|
const supplementalCodecs = '';
|
|
const languageValue = '';
|
|
const channelsCount = null;
|
|
const sampleRate = null;
|
|
const closedCaptions = new Map();
|
|
const spatialAudio = false;
|
|
const characteristics = null;
|
|
const forced = false; // Only relevant for text.
|
|
const primary = true; // This is the only stream!
|
|
const name = 'Media Playlist';
|
|
|
|
// Make the stream info, with those values.
|
|
const streamInfo = await this.convertParsedPlaylistIntoStreamInfo_(
|
|
this.globalId_++, mediaVariables, playlist, getUris, codecs,
|
|
mediaPlaylistType, languageValue, primary, name, channelsCount,
|
|
closedCaptions, characteristics, forced, sampleRate, spatialAudio,
|
|
supplementalCodecs);
|
|
this.uriToStreamInfosMap_.set(this.masterPlaylistUri_, streamInfo);
|
|
|
|
|
|
if (streamInfo.stream) {
|
|
const qualityInfo =
|
|
shaka.media.QualityObserver.createQualityInfo(streamInfo.stream);
|
|
streamInfo.stream.segmentIndex.forEachTopLevelReference(
|
|
(reference) => {
|
|
if (reference.initSegmentReference) {
|
|
reference.initSegmentReference.mediaQuality = qualityInfo;
|
|
}
|
|
});
|
|
}
|
|
|
|
mediaPlaylistType = streamInfo.stream.type;
|
|
|
|
// Wrap the stream from that stream info with a variant.
|
|
let variantAllowed = true;
|
|
if (this.config_.disableAudio && streamInfo.type == 'audio') {
|
|
variantAllowed = false;
|
|
} else if (this.config_.disableVideo && streamInfo.type == 'video' &&
|
|
!streamInfo.stream.codecs.includes(',')) {
|
|
variantAllowed = false;
|
|
}
|
|
if (variantAllowed) {
|
|
variants.push({
|
|
id: 0,
|
|
language: this.getLanguage_(languageValue),
|
|
disabledUntilTime: 0,
|
|
primary: true,
|
|
audio: streamInfo.type == 'audio' ? streamInfo.stream : null,
|
|
video: streamInfo.type == 'video' ? streamInfo.stream : null,
|
|
bandwidth: streamInfo.stream.bandwidth || 0,
|
|
allowedByApplication: true,
|
|
allowedByKeySystem: true,
|
|
decodingInfos: [],
|
|
});
|
|
}
|
|
} else {
|
|
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');
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const iFrameTags = Utils.filterTagsByName(
|
|
playlist.tags, 'EXT-X-I-FRAME-STREAM-INF');
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const sessionKeyTags = Utils.filterTagsByName(
|
|
playlist.tags, 'EXT-X-SESSION-KEY');
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const sessionDataTags = Utils.filterTagsByName(
|
|
playlist.tags, 'EXT-X-SESSION-DATA');
|
|
/** @type {!Array<!shaka.hls.Tag>} */
|
|
const contentSteeringTags = Utils.filterTagsByName(
|
|
playlist.tags, 'EXT-X-CONTENT-STEERING');
|
|
|
|
const chapterUris = this.processSessionData_(sessionDataTags);
|
|
await this.processContentSteering_(contentSteeringTags);
|
|
|
|
if (!this.config_.ignoreSupplementalCodecs) {
|
|
// Duplicate variant tags with supplementalCodecs
|
|
const newVariantTags = [];
|
|
for (const tag of variantTags) {
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(tag);
|
|
if (!supplementalCodecs) {
|
|
continue;
|
|
}
|
|
const newAttributes = tag.attributes.map((attr) => {
|
|
const name = attr.name;
|
|
let value = attr.value;
|
|
if (name == 'CODECS') {
|
|
value = supplementalCodecs;
|
|
const allCodecs = attr.value.split(',');
|
|
if (allCodecs.length > 1) {
|
|
const audioCodec =
|
|
shaka.util.ManifestParserUtils.guessCodecsSafe(
|
|
shaka.util.ManifestParserUtils.ContentType.AUDIO,
|
|
allCodecs);
|
|
if (audioCodec) {
|
|
value += ',' + audioCodec;
|
|
}
|
|
}
|
|
}
|
|
return new shaka.hls.Attribute(name, value);
|
|
});
|
|
newVariantTags.push(
|
|
new shaka.hls.Tag(tag.id, tag.name, newAttributes, null));
|
|
}
|
|
variantTags.push(...newVariantTags);
|
|
|
|
// Duplicate iFrame tags with supplementalCodecs
|
|
const newIFrameTags = [];
|
|
for (const tag of iFrameTags) {
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(tag);
|
|
if (!supplementalCodecs) {
|
|
continue;
|
|
}
|
|
const newAttributes = tag.attributes.map((attr) => {
|
|
const name = attr.name;
|
|
let value = attr.value;
|
|
if (name == 'CODECS') {
|
|
value = supplementalCodecs;
|
|
}
|
|
return new shaka.hls.Attribute(name, value);
|
|
});
|
|
newIFrameTags.push(
|
|
new shaka.hls.Tag(tag.id, tag.name, newAttributes, null));
|
|
}
|
|
iFrameTags.push(...newIFrameTags);
|
|
}
|
|
|
|
this.parseCodecs_(variantTags);
|
|
|
|
this.parseClosedCaptions_(mediaTags);
|
|
const iFrameStreams = this.parseIFrames_(iFrameTags);
|
|
variants = await this.createVariantsForTags_(
|
|
variantTags, sessionKeyTags, mediaTags, getUris,
|
|
this.globalVariables_, iFrameStreams);
|
|
textStreams = this.parseTexts_(mediaTags);
|
|
[imageStreams, chapterStreams] = await Promise.all([
|
|
this.parseImages_(imageTags, iFrameTags),
|
|
this.parseChapters_(chapterUris),
|
|
]);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
this.determineStartTime_(playlist);
|
|
|
|
// Single-variant streams aren't lazy-loaded, so for them we already have
|
|
// enough info here to determine the presentation type and duration.
|
|
if (playlist.type == shaka.hls.PlaylistType.MEDIA) {
|
|
if (this.isLive_()) {
|
|
this.changePresentationTimelineToLive_(playlist);
|
|
const delay = this.getUpdatePlaylistDelay_();
|
|
this.updatePlaylistTimer_.tickAfter(/* seconds= */ delay);
|
|
}
|
|
const streamInfos = Array.from(this.uriToStreamInfosMap_.values());
|
|
this.finalizeStreams_(streamInfos);
|
|
this.determineDuration_();
|
|
|
|
goog.asserts.assert(mediaPlaylistType,
|
|
'mediaPlaylistType should be non-null');
|
|
await this.processDateRangeTags_(
|
|
playlist.tags, mediaPlaylistType, mediaVariables, getUris);
|
|
}
|
|
|
|
this.manifest_ = {
|
|
presentationTimeline: this.presentationTimeline_,
|
|
variants,
|
|
textStreams,
|
|
imageStreams,
|
|
chapterStreams,
|
|
offlineSessionIds: [],
|
|
sequenceMode: this.config_.hls.sequenceMode,
|
|
ignoreManifestTimestampsInSegmentsMode:
|
|
this.config_.hls.ignoreManifestTimestampsInSegmentsMode,
|
|
type: shaka.media.ManifestParser.HLS,
|
|
serviceDescription: null,
|
|
nextUrl: null,
|
|
periodCount: 1,
|
|
gapCount: 0,
|
|
isLowLatency: false,
|
|
startTime: this.startTime_,
|
|
};
|
|
|
|
// If there is no 'CODECS' attribute in the manifest and codec guessing is
|
|
// disabled, we need to create the segment indexes now so that missing info
|
|
// can be parsed from the media data and added to the stream objects.
|
|
if (!this.codecInfoInManifest_ && this.config_.hls.disableCodecGuessing) {
|
|
const createIndexes = [];
|
|
for (const variant of this.manifest_.variants) {
|
|
if (variant.audio && variant.audio.codecs === '') {
|
|
createIndexes.push(variant.audio.createSegmentIndex());
|
|
}
|
|
if (variant.video && variant.video.codecs === '') {
|
|
createIndexes.push(variant.video.createSegmentIndex());
|
|
}
|
|
}
|
|
|
|
await Promise.all(createIndexes);
|
|
}
|
|
|
|
this.playerInterface_.makeTextStreamsForClosedCaptions(this.manifest_);
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.media.SegmentReference>} segments
|
|
* @return {!Promise<shaka.media.SegmentUtils.BasicInfo>}
|
|
* @private
|
|
*/
|
|
async getBasicInfoFromSegments_(segments) {
|
|
const defaultBasicInfo = shaka.media.SegmentUtils.getBasicInfoFromMimeType(
|
|
this.config_.hls.mediaPlaylistFullMimeType);
|
|
if (!segments.length) {
|
|
return defaultBasicInfo;
|
|
}
|
|
const {segment, segmentIndex} = this.getAvailableSegment_(segments);
|
|
const segmentUris = segment.getUris();
|
|
const extension = shaka.net.NetworkingUtils.getExtension(segmentUris[0]);
|
|
if (extension) {
|
|
const rawMimeType =
|
|
shaka.hls.HlsParser.RAW_FORMATS_TO_MIME_TYPES_.get(extension);
|
|
if (rawMimeType) {
|
|
return shaka.media.SegmentUtils.getBasicInfoFromMimeType(
|
|
rawMimeType);
|
|
}
|
|
}
|
|
|
|
const basicInfos = await Promise.all([
|
|
this.getInfoFromSegment_(segment.initSegmentReference, 0),
|
|
this.getInfoFromSegment_(segment, segmentIndex),
|
|
]);
|
|
const contentMimeType = basicInfos[1].mimeType;
|
|
const initData = basicInfos[0].data;
|
|
const data = basicInfos[1].data;
|
|
|
|
const validMp4Extensions = [
|
|
'mp4',
|
|
'mp4a',
|
|
'm4s',
|
|
'm4i',
|
|
'm4a',
|
|
'm4f',
|
|
'cmfa',
|
|
'mp4v',
|
|
'm4v',
|
|
'cmfv',
|
|
'fmp4',
|
|
];
|
|
const validMp4MimeType = [
|
|
'audio/mp4',
|
|
'video/mp4',
|
|
'video/iso.segment',
|
|
];
|
|
|
|
// The extension isn't always a good indicator that it's MP4, so we don't
|
|
// take it into account during the first validation.
|
|
const isMp4 = segment.initSegmentReference ||
|
|
validMp4MimeType.includes(contentMimeType);
|
|
|
|
if (!isMp4 && shaka.util.TsParser.probe(
|
|
shaka.util.BufferUtils.toUint8(data))) {
|
|
const basicInfo = shaka.media.SegmentUtils.getBasicInfoFromTs(
|
|
data, this.config_.disableAudio, this.config_.disableVideo,
|
|
this.config_.disableText);
|
|
if (basicInfo) {
|
|
return basicInfo;
|
|
}
|
|
} else if (isMp4 || validMp4Extensions.includes(extension)) {
|
|
const basicInfo = shaka.media.SegmentUtils.getBasicInfoFromMp4(
|
|
initData, data, this.config_.disableText);
|
|
if (basicInfo) {
|
|
return basicInfo;
|
|
}
|
|
}
|
|
if (contentMimeType) {
|
|
return shaka.media.SegmentUtils.getBasicInfoFromMimeType(
|
|
contentMimeType);
|
|
}
|
|
return defaultBasicInfo;
|
|
}
|
|
|
|
/**
|
|
* @param {?shaka.media.AnySegmentReference} segment
|
|
* @param {number} segmentIndex
|
|
* @return {!Promise<{mimeType: ?string, data: ?BufferSource}>}
|
|
* @private
|
|
*/
|
|
async getInfoFromSegment_(segment, segmentIndex) {
|
|
if (!segment) {
|
|
return {mimeType: null, data: null};
|
|
}
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
|
|
const segmentRequest = shaka.util.Networking.createSegmentRequest(
|
|
segment.getUris(), segment.getStartByte(), segment.getEndByte(),
|
|
this.config_.retryParameters);
|
|
const type = segment instanceof shaka.media.SegmentReference ?
|
|
shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_SEGMENT :
|
|
shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT;
|
|
const response = await this.makeNetworkRequest_(
|
|
segmentRequest, requestType, {type}).promise;
|
|
let data = response.data;
|
|
if (segment.aesKey) {
|
|
data = await shaka.media.SegmentUtils.aesDecrypt(
|
|
data, segment.aesKey, segmentIndex);
|
|
}
|
|
if (segment instanceof shaka.media.SegmentReference) {
|
|
segment.setSegmentData(data, /* singleUse= */ true);
|
|
} else {
|
|
segment.setSegmentData(data);
|
|
}
|
|
|
|
let mimeType = response.headers['content-type'];
|
|
if (mimeType) {
|
|
// Split the MIME type in case the server sent additional parameters.
|
|
mimeType = mimeType.split(';')[0].toLowerCase();
|
|
}
|
|
return {mimeType, data};
|
|
}
|
|
|
|
/** @private */
|
|
determineDuration_() {
|
|
goog.asserts.assert(this.presentationTimeline_,
|
|
'Presentation timeline not created!');
|
|
|
|
if (this.isLive_()) {
|
|
// 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) {
|
|
let segmentAvailabilityDuration = this.getLiveDuration_() || 0;
|
|
|
|
// 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 {
|
|
// Use the minimum duration as the presentation duration.
|
|
this.presentationTimeline_.setDuration(this.getMinDuration_());
|
|
}
|
|
|
|
if (!this.presentationTimeline_.isStartTimeLocked()) {
|
|
for (const streamInfo of this.uriToStreamInfosMap_.values()) {
|
|
if (!streamInfo.stream.segmentIndex) {
|
|
continue; // Not active.
|
|
}
|
|
if (streamInfo.type != 'audio' && streamInfo.type != 'video') {
|
|
continue;
|
|
}
|
|
const firstReference =
|
|
streamInfo.stream.segmentIndex.earliestReference();
|
|
if (firstReference && firstReference.syncTime) {
|
|
const syncTime = firstReference.syncTime;
|
|
this.presentationTimeline_.setInitialProgramDateTime(syncTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is the first point where we have a meaningful presentation start
|
|
// time, and we need to tell PresentationTimeline that so that it can
|
|
// maintain consistency from here on.
|
|
this.presentationTimeline_.lockStartTime();
|
|
|
|
// 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 variables of each variant tag, and store in a map.
|
|
* @param {!Array<!shaka.hls.Tag>} tags Variant tags from the playlist.
|
|
* @private
|
|
*/
|
|
parseMasterVariables_(tags) {
|
|
const queryParams = new goog.Uri(this.masterPlaylistUri_).getQueryData();
|
|
for (const variableTag of tags) {
|
|
const name = variableTag.getAttributeValue('NAME');
|
|
const value = variableTag.getAttributeValue('VALUE');
|
|
const queryParam = variableTag.getAttributeValue('QUERYPARAM');
|
|
if (name && value) {
|
|
if (!this.globalVariables_.has(name)) {
|
|
this.globalVariables_.set(name, value);
|
|
}
|
|
}
|
|
if (queryParam) {
|
|
const queryParamValue = queryParams.get(queryParam)[0];
|
|
if (queryParamValue && !this.globalVariables_.has(queryParamValue)) {
|
|
this.globalVariables_.set(queryParam, queryParamValue);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the variables of each variant tag, and store in a map.
|
|
* @param {!Array<!shaka.hls.Tag>} tags Variant tags from the playlist.
|
|
* @param {string} uri Media playlist URI.
|
|
* @return {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
parseMediaVariables_(tags, uri) {
|
|
const queryParams = new goog.Uri(uri).getQueryData();
|
|
const mediaVariables = new Map();
|
|
for (const variableTag of tags) {
|
|
const name = variableTag.getAttributeValue('NAME');
|
|
const value = variableTag.getAttributeValue('VALUE');
|
|
const queryParam = variableTag.getAttributeValue('QUERYPARAM');
|
|
const mediaImport = variableTag.getAttributeValue('IMPORT');
|
|
if (name && value) {
|
|
if (!mediaVariables.has(name)) {
|
|
mediaVariables.set(name, value);
|
|
}
|
|
}
|
|
if (queryParam) {
|
|
const queryParamValue = queryParams.get(queryParam)[0];
|
|
if (queryParamValue && !mediaVariables.has(queryParamValue)) {
|
|
mediaVariables.set(queryParam, queryParamValue);
|
|
}
|
|
}
|
|
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 array list.
|
|
* @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) {
|
|
let codecs = shaka.util.ManifestParserUtils.guessCodecsSafe(
|
|
ContentType.AUDIO, allCodecs);
|
|
if (!codecs) {
|
|
codecs = this.config_.hls.defaultAudioCodec;
|
|
}
|
|
this.groupIdToCodecsMap_.set(audioGroupId, codecs);
|
|
}
|
|
if (videoGroupId) {
|
|
let codecs = shaka.util.ManifestParserUtils.guessCodecsSafe(
|
|
ContentType.VIDEO, allCodecs);
|
|
if (!codecs) {
|
|
codecs = this.config_.hls.defaultVideoCodec;
|
|
}
|
|
this.groupIdToCodecsMap_.set(videoGroupId, codecs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process EXT-X-SESSION-DATA tags.
|
|
*
|
|
* @param {!Array<!shaka.hls.Tag>} tags
|
|
* @return {!Array<string>} Returns the chapter uris
|
|
* @private
|
|
*/
|
|
processSessionData_(tags) {
|
|
const chapterUris = [];
|
|
for (const tag of tags) {
|
|
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.constructSegmentUris(
|
|
[this.masterPlaylistUri_], uri, this.globalVariables_)[0]);
|
|
}
|
|
if (language) {
|
|
data.set('language', language);
|
|
}
|
|
if (value) {
|
|
data.set('value', value);
|
|
}
|
|
if (id == 'com.apple.hls.chapters' && uri) {
|
|
chapterUris.push(data.get('uri'));
|
|
}
|
|
const event = new shaka.util.FakeEvent('sessiondata', data);
|
|
if (this.playerInterface_) {
|
|
this.playerInterface_.onEvent(event);
|
|
}
|
|
}
|
|
return chapterUris;
|
|
}
|
|
|
|
/**
|
|
* Process EXT-X-CONTENT-STEERING tags.
|
|
*
|
|
* @param {!Array<!shaka.hls.Tag>} tags
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async processContentSteering_(tags) {
|
|
if (!this.playerInterface_ || !this.config_) {
|
|
return;
|
|
}
|
|
let contentSteeringPromise;
|
|
for (const tag of tags) {
|
|
const defaultPathwayId = tag.getAttributeValue('PATHWAY-ID');
|
|
const uri = tag.getAttributeValue('SERVER-URI');
|
|
if (!defaultPathwayId || !uri) {
|
|
continue;
|
|
}
|
|
this.contentSteeringManager_ =
|
|
new shaka.util.ContentSteeringManager(this.playerInterface_);
|
|
this.contentSteeringManager_.configure(this.config_);
|
|
this.contentSteeringManager_.setBaseUris([this.masterPlaylistUri_]);
|
|
this.contentSteeringManager_.setManifestType(
|
|
shaka.media.ManifestParser.HLS);
|
|
this.contentSteeringManager_.setDefaultPathwayId(defaultPathwayId);
|
|
contentSteeringPromise =
|
|
this.contentSteeringManager_.requestInfo(uri);
|
|
break;
|
|
}
|
|
await contentSteeringPromise;
|
|
}
|
|
|
|
/**
|
|
* 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 {!Array<!shaka.extern.Stream>}
|
|
* @private
|
|
*/
|
|
parseTexts_(mediaTags) {
|
|
// Create text stream for each Subtitle media tag.
|
|
const subtitleTags =
|
|
shaka.hls.Utils.filterTagsByType(mediaTags, 'SUBTITLES');
|
|
const textStreams = subtitleTags.map((tag) => {
|
|
const disableText = this.config_.disableText;
|
|
if (disableText) {
|
|
return null;
|
|
}
|
|
try {
|
|
return this.createStreamInfoFromMediaTags_([tag], new Map()).stream;
|
|
} catch (e) {
|
|
if (this.config_.hls.ignoreTextStreamFailures) {
|
|
return null;
|
|
}
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
const type = shaka.util.ManifestParserUtils.ContentType.TEXT;
|
|
|
|
// 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;
|
|
textStreamInfo.stream.mimeType =
|
|
this.guessMimeTypeBeforeLoading_(type, codecs) ||
|
|
this.guessMimeTypeFallback_(type);
|
|
this.setFullTypeForStream_(textStreamInfo.stream);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Do not create text streams for Closed captions.
|
|
return textStreams.filter((s) => s);
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.extern.Stream} stream
|
|
* @private
|
|
*/
|
|
setFullTypeForStream_(stream) {
|
|
const combinations = new Set([shaka.util.MimeUtils.getFullType(
|
|
stream.mimeType, stream.codecs)]);
|
|
if (stream.segmentIndex) {
|
|
stream.segmentIndex.forEachTopLevelReference((reference) => {
|
|
if (reference.mimeType) {
|
|
combinations.add(shaka.util.MimeUtils.getFullType(
|
|
reference.mimeType, stream.codecs));
|
|
}
|
|
});
|
|
}
|
|
stream.fullMimeTypes = combinations;
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.Tag>} imageTags from the playlist.
|
|
* @param {!Array<!shaka.hls.Tag>} iFrameTags from the playlist.
|
|
* @return {!Promise<!Array<!shaka.extern.Stream>>}
|
|
* @private
|
|
*/
|
|
async parseImages_(imageTags, iFrameTags) {
|
|
// 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);
|
|
return streamInfo.stream;
|
|
} catch (e) {
|
|
if (this.config_.hls.ignoreImageStreamFailures) {
|
|
return null;
|
|
}
|
|
throw e;
|
|
}
|
|
}).concat(iFrameTags.map((tag) => {
|
|
const disableThumbnails = this.config_.disableThumbnails;
|
|
if (disableThumbnails) {
|
|
return null;
|
|
}
|
|
try {
|
|
const streamInfo = this.createStreamInfoFromIframeTag_(tag);
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
if (streamInfo.stream.type !== ContentType.IMAGE) {
|
|
return null;
|
|
}
|
|
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<string>} chapterUris
|
|
* @return {!Promise<!Array<!shaka.extern.Stream>>}
|
|
* @private
|
|
*/
|
|
async parseChapters_(chapterUris) {
|
|
const chapterStreams = [];
|
|
|
|
if (this.config_.disableChapters) {
|
|
return chapterStreams;
|
|
}
|
|
|
|
const promises = [];
|
|
for (const uri of chapterUris) {
|
|
promises.push(this.processChapters_(uri, chapterStreams));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
|
|
return chapterStreams;
|
|
}
|
|
|
|
/**
|
|
* @param {string} uri
|
|
* @param {!Array<!shaka.extern.Stream>} chapterStreams
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async processChapters_(uri, chapterStreams) {
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.SESSION_DATA;
|
|
const request = shaka.net.NetworkingEngine.makeRequest(
|
|
[uri], this.config_.retryParameters);
|
|
const response = await this.makeNetworkRequest_(
|
|
request, requestType).promise;
|
|
const data = shaka.util.StringUtils.fromUTF8(response.data);
|
|
/** @type {!HlsSpec.ChapterList} */
|
|
const chapterList = /** @type {!HlsSpec.ChapterList} */ (JSON.parse(data));
|
|
|
|
/** @type {!Map<string, !Array<shaka.extern.Chapter>>} */
|
|
const chapterByLanguage = new Map();
|
|
for (let i = 0; i < chapterList.length; i++) {
|
|
const chapter = chapterList[i];
|
|
if (!chapter.titles) {
|
|
continue;
|
|
}
|
|
for (const title of chapter.titles) {
|
|
/** @type {shaka.extern.Chapter} */
|
|
const item = {
|
|
id: '',
|
|
title: title.title,
|
|
startTime: chapter['start-time'],
|
|
endTime: chapter['start-time'],
|
|
};
|
|
if (chapter.duration) {
|
|
item.endTime += chapter.duration;
|
|
} else if ((i + 1) < chapterList.length) {
|
|
item.endTime = chapterList[i + 1]['start-time'];
|
|
}
|
|
if (!chapterByLanguage.has(title.language)) {
|
|
chapterByLanguage.set(title.language, []);
|
|
}
|
|
chapterByLanguage.get(title.language).push(item);
|
|
}
|
|
}
|
|
|
|
chapterByLanguage.forEach((chapters, language) => {
|
|
const references = [];
|
|
for (const chapter of chapters) {
|
|
const reference = new shaka.media.SegmentReference(
|
|
chapter.startTime,
|
|
chapter.endTime,
|
|
() => [chapter.title],
|
|
/* startByte= */ 0,
|
|
/* endByte= */ null,
|
|
/* initSegmentReference= */ null,
|
|
/* timestampOffset= */ 0,
|
|
/* appendWindowStart= */ 0,
|
|
/* appendWindowEnd= */ Infinity,
|
|
);
|
|
references.push(reference);
|
|
}
|
|
|
|
const chaptersMimeType = 'text/plain';
|
|
|
|
/** @type {shaka.extern.Stream} */
|
|
const stream = {
|
|
id: this.globalId_++,
|
|
originalId: null,
|
|
groupId: null,
|
|
createSegmentIndex: () => Promise.resolve(),
|
|
segmentIndex: new shaka.media.SegmentIndex(references),
|
|
mimeType: chaptersMimeType,
|
|
codecs: '',
|
|
supplementalCodecs: '',
|
|
kind: '',
|
|
encrypted: false,
|
|
drmInfos: [],
|
|
keyIds: new Set(),
|
|
language: language,
|
|
originalLanguage: language,
|
|
label: null,
|
|
type: shaka.util.ManifestParserUtils.ContentType.CHAPTER,
|
|
primary: false,
|
|
trickModeVideo: null,
|
|
dependencyStream: null,
|
|
emsgSchemeIdUris: null,
|
|
roles: [],
|
|
forced: false,
|
|
channelsCount: null,
|
|
audioSamplingRate: null,
|
|
spatialAudio: false,
|
|
closedCaptions: null,
|
|
accessibilityPurpose: null,
|
|
external: true,
|
|
fastSwitching: false,
|
|
fullMimeTypes: new Set([shaka.util.MimeUtils.getFullType(
|
|
chaptersMimeType, '')]),
|
|
isAudioMuxedInVideo: false,
|
|
baseOriginalId: null,
|
|
};
|
|
|
|
chapterStreams.push(stream);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.Tag>} mediaTags Media tags from the playlist.
|
|
* @param {!Map<string, string>} groupIdPathwayIdMapping
|
|
* @private
|
|
*/
|
|
createStreamInfosFromMediaTags_(mediaTags, groupIdPathwayIdMapping) {
|
|
// Filter out subtitles and media tags without uri (except audio).
|
|
mediaTags = mediaTags.filter((tag) => {
|
|
const uri = tag.getAttributeValue('URI') || '';
|
|
const type = tag.getAttributeValue('TYPE');
|
|
return type != 'SUBTITLES' && (uri != '' || type == 'AUDIO');
|
|
});
|
|
|
|
const groupedTags = {};
|
|
for (const tag of mediaTags) {
|
|
const key = tag.getTagKey(!this.contentSteeringManager_);
|
|
if (!groupedTags[key]) {
|
|
groupedTags[key] = [tag];
|
|
} else {
|
|
groupedTags[key].push(tag);
|
|
}
|
|
}
|
|
|
|
for (const key in groupedTags) {
|
|
// Create stream info for each audio / video media grouped tag.
|
|
this.createStreamInfoFromMediaTags_(
|
|
groupedTags[key], groupIdPathwayIdMapping, /* requireUri= */ false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.Tag>} iFrameTags from the playlist.
|
|
* @return {!Array<!shaka.extern.Stream>}
|
|
* @private
|
|
*/
|
|
parseIFrames_(iFrameTags) {
|
|
// Create iFrame stream for each iFrame tag.
|
|
const iFrameStreams = iFrameTags.map((tag) => {
|
|
const streamInfo = this.createStreamInfoFromIframeTag_(tag);
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
if (streamInfo.stream.type !== ContentType.VIDEO) {
|
|
return null;
|
|
}
|
|
return streamInfo.stream;
|
|
});
|
|
|
|
// Filter mjpg iFrames
|
|
return iFrameStreams.filter((s) => s);
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.Tag>} tags Variant tags from the playlist.
|
|
* @param {!Array<!shaka.hls.Tag>} sessionKeyTags EXT-X-SESSION-KEY tags
|
|
* from the playlist.
|
|
* @param {!Array<!shaka.hls.Tag>} mediaTags EXT-X-MEDIA tags from the
|
|
* playlist.
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {?Map<string, string>} variables
|
|
* @param {!Array<!shaka.extern.Stream>} iFrameStreams
|
|
* @return {!Promise<!Array<!shaka.extern.Variant>>}
|
|
* @private
|
|
*/
|
|
async createVariantsForTags_(tags, sessionKeyTags, mediaTags, getUris,
|
|
variables, iFrameStreams) {
|
|
// EXT-X-SESSION-KEY processing
|
|
const drmInfos = [];
|
|
const keyIds = new Set();
|
|
if (!this.config_.ignoreDrmInfo && sessionKeyTags.length > 0) {
|
|
for (const drmTag of sessionKeyTags) {
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
// According to the HLS spec, KEYFORMAT is optional and implicitly
|
|
// defaults to "identity".
|
|
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
|
|
const keyFormat =
|
|
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
|
|
let drmInfo = null;
|
|
if (method == 'NONE') {
|
|
continue;
|
|
} else if (this.isAesMethod_(method)) {
|
|
const keyUris = shaka.hls.Utils.constructSegmentUris(
|
|
getUris(), drmTag.getRequiredAttrValue('URI'), variables);
|
|
const keyMapKey = keyUris.sort().join('');
|
|
if (!this.aesKeyMap_.has(keyMapKey)) {
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
|
|
const request = shaka.net.NetworkingEngine.makeRequest(
|
|
keyUris, this.config_.retryParameters);
|
|
const keyResponse = this.makeNetworkRequest_(request, requestType)
|
|
.promise;
|
|
this.aesKeyMap_.set(keyMapKey, keyResponse);
|
|
}
|
|
continue;
|
|
} else if (keyFormat == 'identity') {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
drmInfo = await this.identityDrmParser_(
|
|
drmTag, /* mimeType= */ '', getUris,
|
|
/* initSegmentRef= */ null, variables);
|
|
} else {
|
|
const drmParser =
|
|
this.keyFormatsToDrmParsers_.get(keyFormat);
|
|
|
|
drmInfo = drmParser ?
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await drmParser(drmTag, /* mimeType= */ '',
|
|
/* initSegmentRef= */ null) : 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
const groupedTags = {};
|
|
for (const tag of tags) {
|
|
const key = tag.getTagKey(!this.contentSteeringManager_);
|
|
if (!groupedTags[key]) {
|
|
groupedTags[key] = [tag];
|
|
} else {
|
|
groupedTags[key].push(tag);
|
|
}
|
|
}
|
|
|
|
const allVariants = [];
|
|
// Create variants for each group of variant tag.
|
|
for (const key in groupedTags) {
|
|
const tags = groupedTags[key];
|
|
const firstTag = tags[0];
|
|
|
|
const frameRate = firstTag.getAttributeValue('FRAME-RATE');
|
|
const bandwidth =
|
|
Number(firstTag.getRequiredAttrValue('BANDWIDTH')) ||
|
|
Number(firstTag.getAttributeValue('AVERAGE-BANDWIDTH'));
|
|
|
|
const resolution = firstTag.getAttributeValue('RESOLUTION');
|
|
const [width, height] = resolution ? resolution.split('x') : [null, null];
|
|
|
|
const videoRange = firstTag.getAttributeValue('VIDEO-RANGE');
|
|
|
|
let videoLayout = firstTag.getAttributeValue('REQ-VIDEO-LAYOUT');
|
|
if (videoLayout && videoLayout.includes(',')) {
|
|
// If multiple video layout strings are present, pick the first valid
|
|
// one.
|
|
const layoutStrings = videoLayout.split(',').filter((layoutString) => {
|
|
return layoutString == 'CH-STEREO' || layoutString == 'CH-MONO';
|
|
});
|
|
videoLayout = layoutStrings[0];
|
|
}
|
|
// According to the HLS spec:
|
|
// By default a video variant is monoscopic, so an attribute
|
|
// consisting entirely of REQ-VIDEO-LAYOUT="CH-MONO" is unnecessary
|
|
// and SHOULD NOT be present.
|
|
videoLayout = videoLayout || 'CH-MONO';
|
|
|
|
const streamInfos = this.createStreamInfosForVariantTags_(tags,
|
|
mediaTags, resolution, frameRate);
|
|
|
|
goog.asserts.assert(streamInfos.audio.length ||
|
|
streamInfos.video.length, 'We should have created a stream!');
|
|
|
|
allVariants.push(...this.createVariants_(
|
|
streamInfos.audio,
|
|
streamInfos.video,
|
|
bandwidth,
|
|
width,
|
|
height,
|
|
frameRate,
|
|
videoRange,
|
|
videoLayout,
|
|
drmInfos,
|
|
keyIds,
|
|
iFrameStreams));
|
|
}
|
|
return allVariants.filter((variant) => variant != null);
|
|
}
|
|
|
|
/**
|
|
* Create audio and video streamInfos from an 'EXT-X-STREAM-INF' tag and its
|
|
* related media tags.
|
|
*
|
|
* @param {!Array<!shaka.hls.Tag>} tags
|
|
* @param {!Array<!shaka.hls.Tag>} mediaTags
|
|
* @param {?string} resolution
|
|
* @param {?string} frameRate
|
|
* @return {!shaka.hls.HlsParser.StreamInfos}
|
|
* @private
|
|
*/
|
|
createStreamInfosForVariantTags_(tags, mediaTags, resolution, frameRate) {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
/** @type {shaka.hls.HlsParser.StreamInfos} */
|
|
const res = {
|
|
audio: [],
|
|
video: [],
|
|
};
|
|
const groupIdPathwayIdMapping = new Map();
|
|
const globalGroupIds = [];
|
|
let isAudioGroup = false;
|
|
let isVideoGroup = false;
|
|
for (const tag of tags) {
|
|
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;
|
|
if (!groupId) {
|
|
continue;
|
|
}
|
|
if (!globalGroupIds.includes(groupId)) {
|
|
globalGroupIds.push(groupId);
|
|
}
|
|
const pathwayId = tag.getAttributeValue('PATHWAY-ID');
|
|
if (pathwayId) {
|
|
groupIdPathwayIdMapping.set(groupId, pathwayId);
|
|
}
|
|
if (audioGroupId) {
|
|
isAudioGroup = true;
|
|
} else if (videoGroupId) {
|
|
isVideoGroup = true;
|
|
}
|
|
// Make an educated guess about the stream type.
|
|
shaka.log.debug('Guessing stream type for', tag.toString());
|
|
}
|
|
if (globalGroupIds.length && mediaTags.length) {
|
|
const mediaTagsForVariant = mediaTags.filter((tag) => {
|
|
return globalGroupIds.includes(tag.getRequiredAttrValue('GROUP-ID'));
|
|
});
|
|
this.createStreamInfosFromMediaTags_(
|
|
mediaTagsForVariant, groupIdPathwayIdMapping);
|
|
}
|
|
const globalGroupId = globalGroupIds.sort().join(',');
|
|
const streamInfos =
|
|
(globalGroupId && this.groupIdToStreamInfosMap_.has(globalGroupId)) ?
|
|
this.groupIdToStreamInfosMap_.get(globalGroupId) : [];
|
|
if (isAudioGroup) {
|
|
res.audio.push(...streamInfos);
|
|
} else if (isVideoGroup) {
|
|
res.video.push(...streamInfos);
|
|
}
|
|
|
|
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 allStreamUris = tags.map((tag) => tag.getRequiredAttrValue('URI'));
|
|
const hasSameUri = res.audio.find((audio) => {
|
|
return audio && audio.getUris().find((uri) => {
|
|
return allStreamUris.includes(uri);
|
|
});
|
|
});
|
|
|
|
/** @type {!Array<string>} */
|
|
let allCodecs = this.getCodecsForVariantTag_(tags[0]);
|
|
const videoCodecs = shaka.util.ManifestParserUtils.guessCodecsSafe(
|
|
ContentType.VIDEO, allCodecs);
|
|
const audioCodecs = shaka.util.ManifestParserUtils.guessCodecsSafe(
|
|
ContentType.AUDIO, allCodecs);
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(tags[0]);
|
|
|
|
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.');
|
|
ignoreStream = res.audio.length > 0 &&
|
|
!res.audio.some((a) => a.stream.isAudioMuxedInVideo);
|
|
} else if (!res.audio.length && !res.video.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 && !res.audio.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;
|
|
}
|
|
|
|
if (!ignoreStream) {
|
|
const streamInfo =
|
|
this.createStreamInfoFromVariantTags_(tags, allCodecs, type,
|
|
supplementalCodecs);
|
|
if (streamInfo.stream.type == ContentType.AUDIO &&
|
|
type === ContentType.AUDIO &&
|
|
res.audio.some((a) => a.stream.isAudioMuxedInVideo)) {
|
|
const originalStream = res.audio[0].stream;
|
|
streamInfo.stream.language = originalStream.language;
|
|
streamInfo.stream.channelsCount = originalStream.channelsCount;
|
|
streamInfo.stream.label = originalStream.label;
|
|
streamInfo.stream.audioSamplingRate = originalStream.audioSamplingRate;
|
|
streamInfo.stream.originalId = originalStream.originalId;
|
|
streamInfo.stream.originalLanguage = originalStream.originalLanguage;
|
|
}
|
|
res[streamInfo.stream.type] = [streamInfo];
|
|
}
|
|
return res;
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the codecs from the 'EXT-X-STREAM-INF' tag.
|
|
*
|
|
* @param {!shaka.hls.Tag} tag
|
|
* @return {!Array<string>} codecs
|
|
* @private
|
|
*/
|
|
getCodecsForVariantTag_(tag) {
|
|
let codecsString = tag.getAttributeValue('CODECS') || '';
|
|
|
|
this.codecInfoInManifest_ = codecsString.length > 0;
|
|
|
|
if (!this.codecInfoInManifest_ && !this.config_.hls.disableCodecGuessing) {
|
|
// These are the default codecs to assume if none are specified.
|
|
const defaultCodecsArray = [];
|
|
|
|
if (!this.config_.disableVideo) {
|
|
defaultCodecsArray.push(this.config_.hls.defaultVideoCodec);
|
|
}
|
|
if (!this.config_.disableAudio) {
|
|
defaultCodecsArray.push(this.config_.hls.defaultAudioCodec);
|
|
}
|
|
|
|
codecsString = defaultCodecsArray.join(',');
|
|
}
|
|
|
|
// Strip out internal whitespace while splitting on commas:
|
|
/** @type {!Array<string>} */
|
|
const codecs = codecsString.split(/\s*,\s*/);
|
|
|
|
return shaka.media.SegmentUtils.codecsFiltering(codecs);
|
|
}
|
|
|
|
/**
|
|
* 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 sample rate information for an HLS audio track.
|
|
*
|
|
* @param {!shaka.hls.Tag} tag
|
|
* @return {?number}
|
|
* @private
|
|
*/
|
|
getSampleRate_(tag) {
|
|
const sampleRate = tag.getAttributeValue('SAMPLE-RATE');
|
|
if (!sampleRate) {
|
|
return null;
|
|
}
|
|
return parseInt(sampleRate, 10);
|
|
}
|
|
|
|
/**
|
|
* 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 (EAC-3), 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. For Dolby AC-4 ATMOS,
|
|
* it's necessary search ATMOS word.
|
|
* @see https://developer.apple.com/documentation/http-live-streaming/hls-authoring-specification-for-apple-devices-appendixes
|
|
* @see https://ott.dolby.com/OnDelKits/AC-4/Dolby_AC-4_Online_Delivery_Kit_1.5/Documentation/Specs/AC4_HLS/help_files/topics/hls_playlist_c_codec_indication_ims.html
|
|
*
|
|
* @param {!shaka.hls.Tag} tag
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
isSpatialAudio_(tag) {
|
|
const channels = tag.getAttributeValue('CHANNELS');
|
|
if (!channels) {
|
|
return false;
|
|
}
|
|
const channelsParts = channels.split('/');
|
|
if (channelsParts.length != 2) {
|
|
return false;
|
|
}
|
|
return channelsParts[1] === 'JOC' || channelsParts[1].includes('ATMOS');
|
|
}
|
|
|
|
/**
|
|
* 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 ) {
|
|
if (this.config_.disableText) {
|
|
this.needsClosedCaptionsDetection_ = false;
|
|
return null;
|
|
}
|
|
if (closedCaptionsAttr) {
|
|
if (closedCaptionsAttr != 'NONE') {
|
|
return this.groupIdToClosedCaptionsMap_.get(closedCaptionsAttr);
|
|
}
|
|
this.needsClosedCaptionsDetection_ = false;
|
|
} else if (!closedCaptionsAttr && this.groupIdToClosedCaptionsMap_.size) {
|
|
for (const key of this.groupIdToClosedCaptionsMap_.keys()) {
|
|
return this.groupIdToClosedCaptionsMap_.get(key);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get the normalized language value.
|
|
*
|
|
* @param {?string} languageValue
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
getLanguage_(languageValue) {
|
|
const LanguageUtils = shaka.util.LanguageUtils;
|
|
return LanguageUtils.normalize(languageValue || 'und');
|
|
}
|
|
|
|
/**
|
|
* Get the type value.
|
|
* Shaka recognizes the content types 'audio', 'video', 'text', and 'image'.
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
* @param {?string} videoLayout
|
|
* @param {!Array<shaka.extern.DrmInfo>} drmInfos
|
|
* @param {!Set<string>} keyIds
|
|
* @param {!Array<!shaka.extern.Stream>} iFrameStreams
|
|
* @return {!Array<!shaka.extern.Variant>}
|
|
* @private
|
|
*/
|
|
createVariants_(
|
|
audioInfos, videoInfos, bandwidth, width, height, frameRate, videoRange,
|
|
videoLayout, drmInfos, keyIds, iFrameStreams) {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
const DrmUtils = shaka.drm.DrmUtils;
|
|
|
|
for (const info of videoInfos) {
|
|
this.addVideoAttributes_(
|
|
info.stream, width, height, frameRate, videoRange, videoLayout,
|
|
/** colorGamut= */ null);
|
|
}
|
|
|
|
// 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 disableVideo = this.config_.disableVideo;
|
|
if (!videoInfos.length || disableVideo) {
|
|
videoInfos = [null];
|
|
}
|
|
if (disableVideo) {
|
|
audioInfos = audioInfos.filter((streamInfo) => {
|
|
return !streamInfo.stream.isAudioMuxedInVideo;
|
|
});
|
|
}
|
|
const disableAudio = this.config_.disableAudio;
|
|
if (!audioInfos.length || disableAudio) {
|
|
audioInfos = [null];
|
|
}
|
|
|
|
const variants = [];
|
|
for (const audioInfo of audioInfos) {
|
|
for (const videoInfo of videoInfos) {
|
|
const audioStream = audioInfo ? audioInfo.stream : null;
|
|
if (audioStream) {
|
|
audioStream.drmInfos = drmInfos;
|
|
audioStream.keyIds = keyIds;
|
|
}
|
|
const videoStream = videoInfo ? videoInfo.stream : null;
|
|
if (videoStream) {
|
|
videoStream.drmInfos = drmInfos;
|
|
videoStream.keyIds = keyIds;
|
|
if (!this.config_.disableIFrames) {
|
|
shaka.util.StreamUtils.setBetterIFrameStream(
|
|
videoStream, iFrameStreams);
|
|
}
|
|
}
|
|
if (videoStream && !audioStream) {
|
|
videoStream.bandwidth = bandwidth;
|
|
}
|
|
if (!videoStream && audioStream) {
|
|
audioStream.bandwidth = bandwidth;
|
|
}
|
|
const audioDrmInfos = audioInfo ? audioInfo.stream.drmInfos : null;
|
|
const videoDrmInfos = videoInfo ? videoInfo.stream.drmInfos : null;
|
|
const videoStreamUri =
|
|
videoInfo ? videoInfo.getUris().sort().join(',') : '';
|
|
const audioStreamUri =
|
|
audioInfo ? audioInfo.getUris().sort().join(',') : '';
|
|
const codecs = [];
|
|
if (audioStream && audioStream.codecs) {
|
|
codecs.push(audioStream.codecs);
|
|
}
|
|
if (videoStream && videoStream.codecs) {
|
|
codecs.push(videoStream.codecs);
|
|
}
|
|
const variantUriKey = [
|
|
videoStreamUri,
|
|
audioStreamUri,
|
|
codecs.sort(),
|
|
].join('-');
|
|
|
|
if (audioStream && videoStream) {
|
|
if (!DrmUtils.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',
|
|
disabledUntilTime: 0,
|
|
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');
|
|
this.needsClosedCaptionsDetection_ = closedCaptionsTags.length == 0;
|
|
for (const tag of closedCaptionsTags) {
|
|
goog.asserts.assert(tag.name == 'EXT-X-MEDIA',
|
|
'Should only be called on media tags!');
|
|
const languageValue = tag.getAttributeValue('LANGUAGE');
|
|
let language = this.getLanguage_(languageValue);
|
|
if (!languageValue) {
|
|
const nameValue = tag.getAttributeValue('NAME');
|
|
if (nameValue) {
|
|
language = nameValue;
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// We need replace SERVICE string by our internal svc string.
|
|
const instreamId = tag.getRequiredAttrValue('INSTREAM-ID')
|
|
.replace('SERVICE', 'svc');
|
|
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 {!Array<!shaka.hls.Tag>} tags
|
|
* @param {!Map<string, string>} groupIdPathwayIdMapping
|
|
* @param {boolean=} requireUri
|
|
* @return {!shaka.hls.HlsParser.StreamInfo}
|
|
* @private
|
|
*/
|
|
createStreamInfoFromMediaTags_(tags, groupIdPathwayIdMapping,
|
|
requireUri = true) {
|
|
const verbatimMediaPlaylistUris = [];
|
|
const globalGroupIds = [];
|
|
const groupIdUriMapping = new Map();
|
|
for (const tag of tags) {
|
|
goog.asserts.assert(tag.name == 'EXT-X-MEDIA',
|
|
'Should only be called on media tags!');
|
|
const id = ++this.globalId_;
|
|
const getFakeUri = () => {
|
|
return shaka.hls.HlsParser.FAKE_MUXED_URL_ + id;
|
|
};
|
|
const uri = requireUri ? tag.getRequiredAttrValue('URI') :
|
|
(tag.getAttributeValue('URI') || getFakeUri());
|
|
const groupId = tag.getRequiredAttrValue('GROUP-ID');
|
|
verbatimMediaPlaylistUris.push(uri);
|
|
globalGroupIds.push(groupId);
|
|
groupIdUriMapping.set(groupId, uri);
|
|
}
|
|
|
|
const globalGroupId = globalGroupIds.sort().join(',');
|
|
const firstTag = tags[0];
|
|
let codecs = '';
|
|
/** @type {string} */
|
|
const type = this.getType_(firstTag);
|
|
if (type == shaka.util.ManifestParserUtils.ContentType.TEXT) {
|
|
codecs = firstTag.getAttributeValue('CODECS') || '';
|
|
} else {
|
|
for (const groupId of globalGroupIds) {
|
|
if (this.groupIdToCodecsMap_.has(groupId)) {
|
|
codecs = this.groupIdToCodecsMap_.get(groupId);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(firstTag);
|
|
|
|
// Check if the stream has already been created as part of another Variant
|
|
// and return it if it has.
|
|
let key = verbatimMediaPlaylistUris.sort().join(',');
|
|
if (type == 'audio' && globalGroupId) {
|
|
key += globalGroupId;
|
|
}
|
|
if (this.uriToStreamInfosMap_.has(key)) {
|
|
return this.uriToStreamInfosMap_.get(key);
|
|
}
|
|
const streamId = this.globalId_++;
|
|
if (this.contentSteeringManager_) {
|
|
for (const [groupId, uri] of groupIdUriMapping) {
|
|
const pathwayId = groupIdPathwayIdMapping.get(groupId);
|
|
if (pathwayId) {
|
|
this.contentSteeringManager_.addLocation(streamId, pathwayId, uri);
|
|
}
|
|
}
|
|
}
|
|
|
|
const language = firstTag.getAttributeValue('LANGUAGE');
|
|
const name = firstTag.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 = firstTag.getAttributeValue('DEFAULT');
|
|
const primary = defaultAttrValue == 'YES';
|
|
|
|
const channelsCount =
|
|
type == 'audio' ? this.getChannelsCount_(firstTag) : null;
|
|
const spatialAudio =
|
|
type == 'audio' ? this.isSpatialAudio_(firstTag) : false;
|
|
const characteristics = firstTag.getAttributeValue('CHARACTERISTICS');
|
|
|
|
const forcedAttrValue = firstTag.getAttributeValue('FORCED');
|
|
const forced = forcedAttrValue == 'YES';
|
|
const sampleRate = type == 'audio' ? this.getSampleRate_(firstTag) : null;
|
|
// TODO: Should we take into account some of the currently ignored
|
|
// attributes: INSTREAM-ID, Attribute descriptions: https://bit.ly/2lpjOhj
|
|
const streamInfo = this.createStreamInfo_(
|
|
streamId, verbatimMediaPlaylistUris, codecs, type, language,
|
|
primary, name, channelsCount, /* closedCaptions= */ null,
|
|
characteristics, forced, sampleRate, spatialAudio, supplementalCodecs);
|
|
if (this.groupIdToStreamInfosMap_.has(globalGroupId)) {
|
|
this.groupIdToStreamInfosMap_.get(globalGroupId).push(streamInfo);
|
|
} else {
|
|
this.groupIdToStreamInfosMap_.set(globalGroupId, [streamInfo]);
|
|
}
|
|
|
|
this.uriToStreamInfosMap_.set(key, streamInfo);
|
|
return streamInfo;
|
|
}
|
|
|
|
/**
|
|
* Parse EXT-X-IMAGE-STREAM-INF 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 = tag.getRequiredAttrValue('URI');
|
|
const codecs = tag.getAttributeValue('CODECS', 'jpeg') || '';
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(tag);
|
|
|
|
// 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 = tag.getAttributeValue('LANGUAGE');
|
|
const name = tag.getAttributeValue('NAME');
|
|
|
|
const characteristics = tag.getAttributeValue('CHARACTERISTICS');
|
|
|
|
const streamInfo = this.createStreamInfo_(
|
|
this.globalId_++, [verbatimImagePlaylistUri], codecs, type, language,
|
|
/* primary= */ false, name, /* channelsCount= */ null,
|
|
/* closedCaptions= */ null, characteristics, /* forced= */ false,
|
|
/* sampleRate= */ null, /* spatialAudio= */ false, supplementalCodecs);
|
|
|
|
// 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.
|
|
|
|
// Since we need to have generated the segment index for this, we can't
|
|
// lazy-load in this situation.
|
|
await streamInfo.stream.createSegmentIndex();
|
|
|
|
const reference = streamInfo.stream.segmentIndex.earliestReference();
|
|
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?
|
|
}
|
|
|
|
streamInfo.stream.closeSegmentIndex();
|
|
}
|
|
const bandwidth = tag.getAttributeValue('BANDWIDTH');
|
|
if (bandwidth) {
|
|
streamInfo.stream.bandwidth = Number(bandwidth);
|
|
}
|
|
|
|
this.uriToStreamInfosMap_.set(verbatimImagePlaylistUri, streamInfo);
|
|
return streamInfo;
|
|
}
|
|
|
|
/**
|
|
* Parse EXT-X-I-FRAME-STREAM-INF media tag into a Stream object.
|
|
*
|
|
* @param {shaka.hls.Tag} tag
|
|
* @return {!shaka.hls.HlsParser.StreamInfo}
|
|
* @private
|
|
*/
|
|
createStreamInfoFromIframeTag_(tag) {
|
|
goog.asserts.assert(tag.name == 'EXT-X-I-FRAME-STREAM-INF',
|
|
'Should only be called on iframe tags!');
|
|
/** @type {string} */
|
|
let type = shaka.util.ManifestParserUtils.ContentType.VIDEO;
|
|
|
|
const verbatimIFramePlaylistUri = tag.getRequiredAttrValue('URI');
|
|
const codecs = tag.getAttributeValue('CODECS') || '';
|
|
const supplementalCodecs = shaka.hls.Utils.getSupplementalCodecs(tag);
|
|
|
|
if (codecs == 'mjpg') {
|
|
type = shaka.util.ManifestParserUtils.ContentType.IMAGE;
|
|
}
|
|
|
|
// Check if the stream has already been created as part of another Variant
|
|
// and return it if it has.
|
|
if (this.uriToStreamInfosMap_.has(verbatimIFramePlaylistUri)) {
|
|
return this.uriToStreamInfosMap_.get(verbatimIFramePlaylistUri);
|
|
}
|
|
|
|
const language = tag.getAttributeValue('LANGUAGE');
|
|
const name = tag.getAttributeValue('NAME');
|
|
|
|
const characteristics = tag.getAttributeValue('CHARACTERISTICS');
|
|
|
|
const streamInfo = this.createStreamInfo_(
|
|
this.globalId_++, [verbatimIFramePlaylistUri], codecs, type, language,
|
|
/* primary= */ false, name, /* channelsCount= */ null,
|
|
/* closedCaptions= */ null, characteristics, /* forced= */ false,
|
|
/* sampleRate= */ null, /* spatialAudio= */ false, supplementalCodecs);
|
|
|
|
// Parse misc attributes.
|
|
const resolution = tag.getAttributeValue('RESOLUTION');
|
|
const [width, height] = resolution ? resolution.split('x') : [null, null];
|
|
streamInfo.stream.width = Number(width) || undefined;
|
|
streamInfo.stream.height = Number(height) || undefined;
|
|
const bandwidth = tag.getAttributeValue('BANDWIDTH');
|
|
if (bandwidth) {
|
|
streamInfo.stream.bandwidth = Number(bandwidth);
|
|
}
|
|
|
|
this.uriToStreamInfosMap_.set(verbatimIFramePlaylistUri, streamInfo);
|
|
return streamInfo;
|
|
}
|
|
|
|
/**
|
|
* Parse an EXT-X-STREAM-INF media tag into a Stream object.
|
|
*
|
|
* @param {!Array<!shaka.hls.Tag>} tags
|
|
* @param {!Array<string>} allCodecs
|
|
* @param {string} type
|
|
* @param {!string} supplementalCodecs
|
|
* @return {!shaka.hls.HlsParser.StreamInfo}
|
|
* @private
|
|
*/
|
|
createStreamInfoFromVariantTags_(tags, allCodecs, type, supplementalCodecs) {
|
|
const streamId = this.globalId_++;
|
|
const verbatimMediaPlaylistUris = [];
|
|
for (const tag of tags) {
|
|
goog.asserts.assert(tag.name == 'EXT-X-STREAM-INF',
|
|
'Should only be called on variant tags!');
|
|
const uri = tag.getRequiredAttrValue('URI');
|
|
const pathwayId = tag.getAttributeValue('PATHWAY-ID');
|
|
if (this.contentSteeringManager_ && pathwayId) {
|
|
this.contentSteeringManager_.addLocation(streamId, pathwayId, uri);
|
|
}
|
|
verbatimMediaPlaylistUris.push(uri);
|
|
}
|
|
|
|
const key = verbatimMediaPlaylistUris.sort().join(',') +
|
|
allCodecs.sort().join(',');
|
|
if (this.uriToStreamInfosMap_.has(key)) {
|
|
return this.uriToStreamInfosMap_.get(key);
|
|
}
|
|
|
|
const name = verbatimMediaPlaylistUris.join(',');
|
|
const closedCaptions = this.getClosedCaptions_(tags[0], type);
|
|
const codecs = shaka.util.ManifestParserUtils.guessCodecs(type, allCodecs);
|
|
const streamInfo = this.createStreamInfo_(
|
|
streamId, verbatimMediaPlaylistUris,
|
|
codecs, type, /* language= */ null, /* primary= */ false,
|
|
name, /* channelCount= */ null, closedCaptions,
|
|
/* characteristics= */ null, /* forced= */ false,
|
|
/* sampleRate= */ null, /* spatialAudio= */ false, supplementalCodecs);
|
|
|
|
this.uriToStreamInfosMap_.set(key, streamInfo);
|
|
return streamInfo;
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {number} streamId
|
|
* @param {!Array<string>} verbatimMediaPlaylistUris
|
|
* @param {string} codecs
|
|
* @param {string} type
|
|
* @param {?string} languageValue
|
|
* @param {boolean} primary
|
|
* @param {?string} name
|
|
* @param {?number} channelsCount
|
|
* @param {Map<string, string>} closedCaptions
|
|
* @param {?string} characteristics
|
|
* @param {boolean} forced
|
|
* @param {?number} sampleRate
|
|
* @param {boolean} spatialAudio
|
|
* @param {string} supplementalCodecs
|
|
* @return {!shaka.hls.HlsParser.StreamInfo}
|
|
* @private
|
|
*/
|
|
createStreamInfo_(streamId, verbatimMediaPlaylistUris, codecs, type,
|
|
languageValue, primary, name, channelsCount, closedCaptions,
|
|
characteristics, forced, sampleRate, spatialAudio, supplementalCodecs) {
|
|
// TODO: Refactor, too many parameters
|
|
|
|
// This stream is lazy-loaded inside the createSegmentIndex function.
|
|
// So we start out with a stream object that does not contain the actual
|
|
// segment index, then download when createSegmentIndex is called.
|
|
const stream = this.makeStreamObject_(streamId, codecs, type,
|
|
languageValue, primary, name, channelsCount, closedCaptions,
|
|
characteristics, forced, sampleRate, spatialAudio, supplementalCodecs);
|
|
|
|
const includesFakeUrl = verbatimMediaPlaylistUris.some((uri) => {
|
|
return uri.startsWith(shaka.hls.HlsParser.FAKE_MUXED_URL_);
|
|
});
|
|
if (includesFakeUrl) {
|
|
stream.isAudioMuxedInVideo = true;
|
|
// We assigned the TS mimetype because it is the only one that works
|
|
// with this functionality. MP4 is not supported right now.
|
|
stream.mimeType = 'video/mp2t';
|
|
this.setFullTypeForStream_(stream);
|
|
}
|
|
|
|
const streamInfo = {
|
|
stream,
|
|
type,
|
|
redirectUris: [],
|
|
getUris: () => {},
|
|
// These values are filled out or updated after lazy-loading:
|
|
minTimestamp: 0,
|
|
maxTimestamp: 0,
|
|
mediaSequenceToStartTime: new Map(),
|
|
canSkipSegments: false,
|
|
canBlockReload: false,
|
|
hasEndList: false,
|
|
firstSequenceNumber: -1,
|
|
nextMediaSequence: -1,
|
|
nextPart: -1,
|
|
};
|
|
|
|
const getUris = () => {
|
|
if (this.contentSteeringManager_ &&
|
|
verbatimMediaPlaylistUris.length > 1) {
|
|
return this.contentSteeringManager_.getLocations(streamId);
|
|
}
|
|
return streamInfo.redirectUris.concat(shaka.hls.Utils.constructUris(
|
|
[this.masterPlaylistUri_], verbatimMediaPlaylistUris,
|
|
this.globalVariables_));
|
|
};
|
|
|
|
streamInfo.getUris = getUris;
|
|
|
|
/** @param {!shaka.net.NetworkingEngine.PendingRequest} pendingRequest */
|
|
const downloadSegmentIndex = async (pendingRequest) => {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
|
|
try {
|
|
// Download the actual manifest.
|
|
const response = await pendingRequest.promise;
|
|
if (pendingRequest.aborted) {
|
|
return;
|
|
}
|
|
|
|
// Record the final URI after redirects.
|
|
const responseUri = response.uri;
|
|
if (responseUri != response.originalUri) {
|
|
const uris = streamInfo.getUris();
|
|
if (!uris.includes(responseUri)) {
|
|
streamInfo.redirectUris.push(responseUri);
|
|
}
|
|
}
|
|
|
|
// Record the redirected, final URI of this media playlist when we parse
|
|
// it.
|
|
/** @type {!shaka.hls.Playlist} */
|
|
const playlist = this.manifestTextParser_.parsePlaylist(response.data);
|
|
|
|
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, responseUri);
|
|
|
|
const mimeType = undefined;
|
|
|
|
let requestBasicInfo = false;
|
|
|
|
// If no codec info was provided in the manifest and codec guessing is
|
|
// disabled we try to get necessary info from the media data.
|
|
if ((!this.codecInfoInManifest_ &&
|
|
this.config_.hls.disableCodecGuessing) ||
|
|
(this.needsClosedCaptionsDetection_ && type == ContentType.VIDEO &&
|
|
!this.config_.hls.disableClosedCaptionsDetection)) {
|
|
if (playlist.segments.length > 0) {
|
|
this.needsClosedCaptionsDetection_ = false;
|
|
requestBasicInfo = true;
|
|
}
|
|
}
|
|
|
|
const allowOverrideMimeType = !this.codecInfoInManifest_ &&
|
|
this.config_.hls.disableCodecGuessing;
|
|
|
|
const wasLive = this.isLive_();
|
|
const realStreamInfo = await this.convertParsedPlaylistIntoStreamInfo_(
|
|
streamId, mediaVariables, playlist, getUris, codecs,
|
|
type, languageValue, primary, name, channelsCount, closedCaptions,
|
|
characteristics, forced, sampleRate, spatialAudio,
|
|
supplementalCodecs, mimeType, requestBasicInfo,
|
|
allowOverrideMimeType);
|
|
if (pendingRequest.aborted) {
|
|
return;
|
|
}
|
|
|
|
const realStream = realStreamInfo.stream;
|
|
|
|
this.determineStartTime_(playlist);
|
|
|
|
if (this.isLive_() && !wasLive) {
|
|
// Now that we know that the presentation is live, convert the
|
|
// timeline to live.
|
|
this.changePresentationTimelineToLive_(playlist);
|
|
}
|
|
|
|
// Copy values from the real stream info to our initial one.
|
|
streamInfo.minTimestamp = realStreamInfo.minTimestamp;
|
|
streamInfo.maxTimestamp = realStreamInfo.maxTimestamp;
|
|
streamInfo.canSkipSegments = realStreamInfo.canSkipSegments;
|
|
streamInfo.canBlockReload = realStreamInfo.canBlockReload;
|
|
streamInfo.hasEndList = realStreamInfo.hasEndList;
|
|
streamInfo.mediaSequenceToStartTime =
|
|
realStreamInfo.mediaSequenceToStartTime;
|
|
streamInfo.nextMediaSequence = realStreamInfo.nextMediaSequence;
|
|
streamInfo.nextPart = realStreamInfo.nextPart;
|
|
stream.segmentIndex = realStream.segmentIndex;
|
|
stream.encrypted = realStream.encrypted;
|
|
stream.drmInfos = realStream.drmInfos;
|
|
stream.keyIds = realStream.keyIds;
|
|
stream.mimeType = realStream.mimeType;
|
|
stream.bandwidth = stream.bandwidth || realStream.bandwidth;
|
|
stream.codecs = stream.codecs || realStream.codecs;
|
|
stream.closedCaptions =
|
|
stream.closedCaptions || realStream.closedCaptions;
|
|
stream.width = stream.width || realStream.width;
|
|
stream.height = stream.height || realStream.height;
|
|
stream.hdr = stream.hdr || realStream.hdr;
|
|
stream.colorGamut = stream.colorGamut || realStream.colorGamut;
|
|
stream.frameRate = stream.frameRate || realStream.frameRate;
|
|
if (stream.language == 'und' && realStream.language != 'und') {
|
|
stream.language = realStream.language;
|
|
}
|
|
stream.language = stream.language || realStream.language;
|
|
stream.channelsCount = stream.channelsCount || realStream.channelsCount;
|
|
stream.audioSamplingRate =
|
|
stream.audioSamplingRate || realStream.audioSamplingRate;
|
|
this.setFullTypeForStream_(stream);
|
|
|
|
// Since we lazy-loaded this content, the player may need to create new
|
|
// sessions for the DRM info in this stream.
|
|
if (stream.drmInfos.length) {
|
|
this.playerInterface_.newDrmInfo(stream);
|
|
}
|
|
|
|
let closedCaptionsUpdated = false;
|
|
if ((!closedCaptions && stream.closedCaptions) ||
|
|
(closedCaptions && stream.closedCaptions &&
|
|
closedCaptions.size != stream.closedCaptions.size)) {
|
|
closedCaptionsUpdated = true;
|
|
}
|
|
|
|
if (this.manifest_ && closedCaptionsUpdated) {
|
|
this.playerInterface_.makeTextStreamsForClosedCaptions(
|
|
this.manifest_);
|
|
}
|
|
|
|
if (type == ContentType.TEXT) {
|
|
const firstSegment = realStream.segmentIndex.earliestReference();
|
|
if (firstSegment && firstSegment.initSegmentReference) {
|
|
stream.mimeType = 'application/mp4';
|
|
this.setFullTypeForStream_(stream);
|
|
}
|
|
}
|
|
|
|
const qualityInfo =
|
|
shaka.media.QualityObserver.createQualityInfo(stream);
|
|
stream.segmentIndex.forEachTopLevelReference((reference) => {
|
|
if (reference.initSegmentReference) {
|
|
reference.initSegmentReference.mediaQuality = qualityInfo;
|
|
}
|
|
});
|
|
|
|
// Add finishing touches to the stream that can only be done once we
|
|
// have more full context on the media as a whole.
|
|
if (this.hasEnoughInfoToFinalizeStreams_()) {
|
|
if (!this.streamsFinalized_) {
|
|
// Mark this manifest as having been finalized, so we don't go
|
|
// through this whole process of finishing touches a second time.
|
|
this.streamsFinalized_ = true;
|
|
// Finalize all of the currently-loaded streams.
|
|
const streamInfos = Array.from(this.uriToStreamInfosMap_.values());
|
|
const activeStreamInfos =
|
|
streamInfos.filter((s) => s.stream.segmentIndex);
|
|
this.finalizeStreams_(activeStreamInfos);
|
|
// With the addition of this new stream, we now have enough info to
|
|
// figure out how long the streams should be. So process all streams
|
|
// we have downloaded up until this point.
|
|
this.determineDuration_();
|
|
// Finally, start the update timer, if this asset has been
|
|
// determined to be a livestream.
|
|
const delay = this.getUpdatePlaylistDelay_();
|
|
if (delay > 0) {
|
|
this.updatePlaylistTimer_.tickAfter(/* seconds= */ delay);
|
|
}
|
|
} else {
|
|
// We don't need to go through the full process; just finalize this
|
|
// single stream.
|
|
this.finalizeStreams_([streamInfo]);
|
|
}
|
|
}
|
|
|
|
await this.processDateRangeTags_(
|
|
playlist.tags, stream.type, mediaVariables, getUris);
|
|
if (pendingRequest.aborted) {
|
|
return;
|
|
}
|
|
|
|
if (this.manifest_) {
|
|
this.manifest_.startTime = this.startTime_;
|
|
}
|
|
} catch (e) {
|
|
stream.closeSegmentIndex();
|
|
if (e.code === shaka.util.Error.Code.OPERATION_ABORTED) {
|
|
return;
|
|
}
|
|
const handled = this.playerInterface_.disableStream(stream);
|
|
if (!handled) {
|
|
throw e;
|
|
}
|
|
}
|
|
};
|
|
|
|
/** @type {Promise} */
|
|
let creationPromise = null;
|
|
/** @type {!shaka.net.NetworkingEngine.PendingRequest} */
|
|
let pendingRequest;
|
|
const safeCreateSegmentIndex = () => {
|
|
// An operation is already in progress. The second and subsequent
|
|
// callers receive the same Promise as the first caller, and only one
|
|
// download operation will occur.
|
|
if (creationPromise) {
|
|
return creationPromise;
|
|
}
|
|
|
|
if (stream.isAudioMuxedInVideo) {
|
|
const segmentIndex = new shaka.media.SegmentIndex([]);
|
|
stream.segmentIndex = segmentIndex;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Create a new PendingRequest to be able to cancel this specific
|
|
// download.
|
|
pendingRequest = this.requestManifest_(streamInfo.getUris(),
|
|
/* isPlaylist= */ true);
|
|
|
|
// Create a Promise tied to the outcome of downloadSegmentIndex(). If
|
|
// downloadSegmentIndex is rejected, creationPromise will also be
|
|
// rejected.
|
|
creationPromise = new Promise((resolve) => {
|
|
resolve(downloadSegmentIndex(pendingRequest));
|
|
});
|
|
return creationPromise;
|
|
};
|
|
|
|
stream.createSegmentIndex = safeCreateSegmentIndex;
|
|
|
|
stream.closeSegmentIndex = () => {
|
|
// If we're mid-creation, cancel it.
|
|
if (creationPromise && !stream.segmentIndex) {
|
|
pendingRequest.abort();
|
|
}
|
|
// If we have a segment index, release it.
|
|
if (stream.segmentIndex) {
|
|
stream.segmentIndex.release();
|
|
stream.segmentIndex = null;
|
|
}
|
|
// Clear the creation Promise so that a new operation can begin.
|
|
creationPromise = null;
|
|
};
|
|
|
|
return streamInfo;
|
|
}
|
|
|
|
/**
|
|
* @return {number}
|
|
* @private
|
|
*/
|
|
getMinDuration_() {
|
|
let minDuration = Infinity;
|
|
for (const streamInfo of this.uriToStreamInfosMap_.values()) {
|
|
if (streamInfo.stream.segmentIndex && streamInfo.stream.type != 'text' &&
|
|
!streamInfo.stream.isAudioMuxedInVideo) {
|
|
// Since everything is already offset to 0 (either by sync or by being
|
|
// VOD), only maxTimestamp is necessary to compute the duration.
|
|
minDuration = Math.min(minDuration, streamInfo.maxTimestamp);
|
|
}
|
|
}
|
|
return minDuration;
|
|
}
|
|
|
|
/**
|
|
* @return {number}
|
|
* @private
|
|
*/
|
|
getLiveDuration_() {
|
|
let maxTimestamp = Infinity;
|
|
let minTimestamp = Infinity;
|
|
for (const streamInfo of this.uriToStreamInfosMap_.values()) {
|
|
if (streamInfo.stream.segmentIndex && streamInfo.stream.type != 'text' &&
|
|
!streamInfo.stream.isAudioMuxedInVideo) {
|
|
maxTimestamp = Math.min(maxTimestamp, streamInfo.maxTimestamp);
|
|
minTimestamp = Math.min(minTimestamp, streamInfo.minTimestamp);
|
|
}
|
|
}
|
|
return maxTimestamp - minTimestamp;
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.extern.Stream>} streams
|
|
* @private
|
|
*/
|
|
notifySegmentsForStreams_(streams) {
|
|
const references = [];
|
|
for (const stream of streams) {
|
|
if (!stream.segmentIndex) {
|
|
// The stream was closed since the list of streams was built.
|
|
continue;
|
|
}
|
|
stream.segmentIndex.forEachTopLevelReference((reference) => {
|
|
references.push(reference);
|
|
});
|
|
}
|
|
this.presentationTimeline_.notifySegments(references);
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.HlsParser.StreamInfo>} streamInfos
|
|
* @private
|
|
*/
|
|
finalizeStreams_(streamInfos) {
|
|
if (!this.isLive_()) {
|
|
const minDuration = this.getMinDuration_();
|
|
for (const streamInfo of streamInfos) {
|
|
streamInfo.stream.segmentIndex.fit(/* periodStart= */ 0, minDuration);
|
|
}
|
|
}
|
|
this.notifySegmentsForStreams_(streamInfos.map((s) => s.stream));
|
|
|
|
const activeStreamInfos = Array.from(this.uriToStreamInfosMap_.values())
|
|
.filter((s) => s.stream.segmentIndex);
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
const hasAudio =
|
|
activeStreamInfos.some((s) => s.stream.type == ContentType.AUDIO);
|
|
const hasVideo =
|
|
activeStreamInfos.some((s) => s.stream.type == ContentType.VIDEO);
|
|
|
|
const liveWithNoProgramDateTime =
|
|
this.isLive_() && !this.usesProgramDateTime_;
|
|
const vodWithOnlyAudioOrVideo = !this.isLive_() &&
|
|
this.usesProgramDateTime_ && !(hasAudio && hasVideo);
|
|
if (this.config_.hls.ignoreManifestProgramDateTime ||
|
|
liveWithNoProgramDateTime || vodWithOnlyAudioOrVideo) {
|
|
this.syncStreamsWithSequenceNumber_(
|
|
streamInfos, liveWithNoProgramDateTime);
|
|
} else {
|
|
this.syncStreamsWithProgramDateTime_(streamInfos);
|
|
if (this.config_.hls.ignoreManifestProgramDateTimeForTypes.length > 0) {
|
|
this.syncStreamsWithSequenceNumber_(streamInfos);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} type
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
ignoreManifestProgramDateTimeFor_(type) {
|
|
if (this.config_.hls.ignoreManifestProgramDateTime) {
|
|
return true;
|
|
}
|
|
const forTypes = this.config_.hls.ignoreManifestProgramDateTimeForTypes;
|
|
return forTypes.includes(type);
|
|
}
|
|
|
|
/**
|
|
* There are some values on streams that can only be set once we know about
|
|
* both the video and audio content, if present.
|
|
* This checks if there is at least one video downloaded (if the media has
|
|
* video), and that there is at least one audio downloaded (if the media has
|
|
* audio).
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
hasEnoughInfoToFinalizeStreams_() {
|
|
if (!this.manifest_) {
|
|
return false;
|
|
}
|
|
const videos = [];
|
|
const audios = [];
|
|
for (const variant of this.manifest_.variants) {
|
|
if (variant.video) {
|
|
videos.push(variant.video);
|
|
}
|
|
if (variant.audio) {
|
|
audios.push(variant.audio);
|
|
}
|
|
}
|
|
if (videos.length > 0 && !videos.some((stream) => stream.segmentIndex)) {
|
|
return false;
|
|
}
|
|
if (audios.length > 0 && !audios.some((stream) => stream.segmentIndex)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {number} streamId
|
|
* @param {!Map<string, string>} variables
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {string} codecs
|
|
* @param {string} type
|
|
* @param {?string} languageValue
|
|
* @param {boolean} primary
|
|
* @param {?string} name
|
|
* @param {?number} channelsCount
|
|
* @param {Map<string, string>} closedCaptions
|
|
* @param {?string} characteristics
|
|
* @param {boolean} forced
|
|
* @param {?number} sampleRate
|
|
* @param {boolean} spatialAudio
|
|
* @param {string} supplementalCodecs
|
|
* @param {(string|undefined)} mimeType
|
|
* @param {boolean=} requestBasicInfo
|
|
* @param {boolean=} allowOverrideMimeType
|
|
* @return {!Promise<!shaka.hls.HlsParser.StreamInfo>}
|
|
* @private
|
|
*/
|
|
async convertParsedPlaylistIntoStreamInfo_(streamId, variables, playlist,
|
|
getUris, codecs, type, languageValue, primary, name,
|
|
channelsCount, closedCaptions, characteristics, forced, sampleRate,
|
|
spatialAudio, supplementalCodecs, mimeType = undefined,
|
|
requestBasicInfo = true, allowOverrideMimeType = true) {
|
|
const playlistSegments = playlist.segments || [];
|
|
const allAreMissing = playlistSegments.every((seg) => {
|
|
if (shaka.hls.Utils.getFirstTagWithName(seg.tags, 'EXT-X-GAP')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (!playlistSegments.length || allAreMissing) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.HLS_EMPTY_MEDIA_PLAYLIST);
|
|
}
|
|
|
|
this.determinePresentationType_(playlist);
|
|
|
|
if (this.isLive_()) {
|
|
this.determineLastTargetDuration_(playlist);
|
|
}
|
|
|
|
const mediaSequenceToStartTime = this.isLive_() ?
|
|
this.mediaSequenceToStartTimeByType_.get(type) : new Map();
|
|
|
|
const {segments, bandwidth} = this.createSegments_(
|
|
playlist, mediaSequenceToStartTime, variables, getUris, type);
|
|
|
|
let width = null;
|
|
let height = null;
|
|
let videoRange = null;
|
|
let colorGamut = null;
|
|
let frameRate = null;
|
|
if (segments.length > 0 && requestBasicInfo) {
|
|
const basicInfo = await this.getBasicInfoFromSegments_(segments);
|
|
|
|
if (type != basicInfo.type && this.isLive_()) {
|
|
this.mediaSequenceToStartTimeByType_.set(
|
|
basicInfo.type, this.mediaSequenceToStartTimeByType_.get(type));
|
|
this.mediaSequenceToStartTimeByType_.set(type, new Map());
|
|
}
|
|
type = basicInfo.type;
|
|
languageValue = basicInfo.language;
|
|
channelsCount = basicInfo.channelCount;
|
|
sampleRate = basicInfo.sampleRate;
|
|
if (!this.config_.disableText) {
|
|
closedCaptions = basicInfo.closedCaptions;
|
|
}
|
|
|
|
height = basicInfo.height;
|
|
width = basicInfo.width;
|
|
videoRange = basicInfo.videoRange;
|
|
colorGamut = basicInfo.colorGamut;
|
|
frameRate = basicInfo.frameRate;
|
|
|
|
if (allowOverrideMimeType) {
|
|
mimeType = basicInfo.mimeType;
|
|
codecs = basicInfo.codecs;
|
|
}
|
|
}
|
|
|
|
if (!mimeType) {
|
|
mimeType = await this.guessMimeType_(type, codecs, segments);
|
|
|
|
// Some manifests don't say what text codec they use, this is a problem
|
|
// if the cmft extension is used because we identify the mimeType as
|
|
// application/mp4. In this case if we don't detect initialization
|
|
// segments, we assume that the mimeType is text/vtt.
|
|
if (type == shaka.util.ManifestParserUtils.ContentType.TEXT &&
|
|
!codecs && mimeType == 'application/mp4' &&
|
|
segments[0] && !segments[0].initSegmentReference) {
|
|
mimeType = 'text/vtt';
|
|
}
|
|
}
|
|
|
|
const {drmInfos, keyIds, encrypted, aesEncrypted} =
|
|
await this.parseDrmInfo_(playlist, mimeType, getUris, variables);
|
|
|
|
if (encrypted && !drmInfos.length && !aesEncrypted) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.HLS_KEYFORMATS_NOT_SUPPORTED);
|
|
}
|
|
|
|
const stream = this.makeStreamObject_(streamId, codecs, type,
|
|
languageValue, primary, name, channelsCount, closedCaptions,
|
|
characteristics, forced, sampleRate, spatialAudio, supplementalCodecs);
|
|
stream.encrypted = encrypted && !aesEncrypted;
|
|
stream.drmInfos = drmInfos;
|
|
stream.keyIds = keyIds;
|
|
stream.mimeType = mimeType;
|
|
if (bandwidth) {
|
|
stream.bandwidth = bandwidth;
|
|
}
|
|
this.setFullTypeForStream_(stream);
|
|
|
|
if (type == shaka.util.ManifestParserUtils.ContentType.VIDEO &&
|
|
(width || height || videoRange || colorGamut)) {
|
|
this.addVideoAttributes_(stream, width, height,
|
|
frameRate, videoRange, /* videoLayout= */ null, colorGamut);
|
|
}
|
|
|
|
// This new calculation is necessary for Low Latency streams.
|
|
if (this.isLive_()) {
|
|
this.determineLastTargetDuration_(playlist);
|
|
}
|
|
|
|
const firstStartTime = segments[0].startTime;
|
|
const lastSegment = segments[segments.length - 1];
|
|
const lastEndTime = lastSegment.endTime;
|
|
/** @type {!shaka.media.SegmentIndex} */
|
|
const segmentIndex = new shaka.media.SegmentIndex(segments);
|
|
stream.segmentIndex = segmentIndex;
|
|
|
|
const serverControlTag = shaka.hls.Utils.getFirstTagWithName(
|
|
playlist.tags, 'EXT-X-SERVER-CONTROL');
|
|
const canSkipSegments = serverControlTag ?
|
|
serverControlTag.getAttribute('CAN-SKIP-UNTIL') != null : false;
|
|
const canBlockReload = serverControlTag ?
|
|
serverControlTag.getAttribute('CAN-BLOCK-RELOAD') != null : false;
|
|
|
|
const mediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber(
|
|
playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0);
|
|
|
|
const {nextMediaSequence, nextPart} =
|
|
this.getNextMediaSequenceAndPart_(mediaSequenceNumber, segments);
|
|
|
|
return {
|
|
stream,
|
|
type,
|
|
redirectUris: [],
|
|
getUris,
|
|
minTimestamp: firstStartTime,
|
|
maxTimestamp: lastEndTime,
|
|
canSkipSegments,
|
|
canBlockReload,
|
|
hasEndList: false,
|
|
firstSequenceNumber: -1,
|
|
nextMediaSequence,
|
|
nextPart,
|
|
mediaSequenceToStartTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the next msn and part
|
|
*
|
|
* @param {number} mediaSequenceNumber
|
|
* @param {!Array<!shaka.media.SegmentReference>} segments
|
|
* @return {{nextMediaSequence: number, nextPart:number}}}
|
|
* @private
|
|
*/
|
|
getNextMediaSequenceAndPart_(mediaSequenceNumber, segments) {
|
|
const currentMediaSequence = mediaSequenceNumber + segments.length - 1;
|
|
let nextMediaSequence = currentMediaSequence;
|
|
let nextPart = -1;
|
|
if (!segments.length) {
|
|
nextMediaSequence++;
|
|
return {
|
|
nextMediaSequence,
|
|
nextPart,
|
|
};
|
|
}
|
|
const lastSegment = segments[segments.length - 1];
|
|
const partialReferences = lastSegment.partialReferences;
|
|
if (!lastSegment.partialReferences.length) {
|
|
nextMediaSequence++;
|
|
if (lastSegment.hasByterangeOptimization()) {
|
|
nextPart = 0;
|
|
}
|
|
return {
|
|
nextMediaSequence,
|
|
nextPart,
|
|
};
|
|
}
|
|
nextPart = partialReferences.length - 1;
|
|
const lastPartialReference =
|
|
partialReferences[partialReferences.length - 1];
|
|
if (!lastPartialReference.isPreload()) {
|
|
nextMediaSequence++;
|
|
nextPart = 0;
|
|
}
|
|
return {
|
|
nextMediaSequence,
|
|
nextPart,
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Creates a stream object with the given parameters.
|
|
* The parameters that are passed into here are only the things that can be
|
|
* known without downloading the media playlist; other values must be set
|
|
* manually on the object after creation.
|
|
* @param {number} id
|
|
* @param {string} codecs
|
|
* @param {string} type
|
|
* @param {?string} languageValue
|
|
* @param {boolean} primary
|
|
* @param {?string} name
|
|
* @param {?number} channelsCount
|
|
* @param {Map<string, string>} closedCaptions
|
|
* @param {?string} characteristics
|
|
* @param {boolean} forced
|
|
* @param {?number} sampleRate
|
|
* @param {boolean} spatialAudio
|
|
* @param {string} supplementalCodecs
|
|
* @return {!shaka.extern.Stream}
|
|
* @private
|
|
*/
|
|
makeStreamObject_(id, codecs, type, languageValue, primary, name,
|
|
channelsCount, closedCaptions, characteristics, forced, sampleRate,
|
|
spatialAudio, supplementalCodecs) {
|
|
// Fill out a "best-guess" mimeType, for now. It will be replaced once the
|
|
// stream is lazy-loaded.
|
|
const mimeType = this.guessMimeTypeBeforeLoading_(type, codecs) ||
|
|
this.guessMimeTypeFallback_(type);
|
|
const roles = [];
|
|
if (characteristics) {
|
|
for (const characteristic of characteristics.split(',')) {
|
|
roles.push(characteristic);
|
|
}
|
|
}
|
|
|
|
let kind = undefined;
|
|
let accessibilityPurpose = null;
|
|
if (type == shaka.util.ManifestParserUtils.ContentType.TEXT) {
|
|
if (roles.includes('public.accessibility.transcribes-spoken-dialog') &&
|
|
roles.includes('public.accessibility.describes-music-and-sound')) {
|
|
kind = shaka.util.ManifestParserUtils.TextStreamKind.CLOSED_CAPTION;
|
|
} else {
|
|
kind = shaka.util.ManifestParserUtils.TextStreamKind.SUBTITLE;
|
|
}
|
|
} else {
|
|
if (roles.includes('public.accessibility.describes-video')) {
|
|
accessibilityPurpose =
|
|
shaka.media.ManifestParser.AccessibilityPurpose.VISUALLY_IMPAIRED;
|
|
}
|
|
}
|
|
|
|
// If there are no roles, and we have defaulted to the subtitle "kind" for
|
|
// this track, add the implied subtitle role.
|
|
if (!roles.length &&
|
|
kind === shaka.util.ManifestParserUtils.TextStreamKind.SUBTITLE) {
|
|
roles.push(shaka.util.ManifestParserUtils.TextStreamKind.SUBTITLE);
|
|
}
|
|
|
|
let label = null;
|
|
if (type != shaka.util.ManifestParserUtils.ContentType.VIDEO) {
|
|
label = name;
|
|
}
|
|
|
|
const stream = {
|
|
id: this.globalId_++,
|
|
originalId: name,
|
|
groupId: null,
|
|
createSegmentIndex: () => Promise.resolve(),
|
|
segmentIndex: null,
|
|
mimeType,
|
|
codecs,
|
|
supplementalCodecs,
|
|
kind: (type == shaka.util.ManifestParserUtils.ContentType.TEXT) ?
|
|
shaka.util.ManifestParserUtils.TextStreamKind.SUBTITLE : undefined,
|
|
encrypted: false,
|
|
drmInfos: [],
|
|
keyIds: new Set(),
|
|
language: this.getLanguage_(languageValue),
|
|
originalLanguage: languageValue,
|
|
label, // For historical reasons, since before "originalId".
|
|
type,
|
|
primary,
|
|
// TODO: trick mode
|
|
trickModeVideo: null,
|
|
dependencyStream: null,
|
|
emsgSchemeIdUris: null,
|
|
frameRate: undefined,
|
|
pixelAspectRatio: undefined,
|
|
width: undefined,
|
|
height: undefined,
|
|
bandwidth: undefined,
|
|
roles,
|
|
forced,
|
|
channelsCount,
|
|
audioSamplingRate: sampleRate,
|
|
spatialAudio,
|
|
closedCaptions,
|
|
hdr: undefined,
|
|
colorGamut: undefined,
|
|
videoLayout: undefined,
|
|
tilesLayout: undefined,
|
|
accessibilityPurpose: accessibilityPurpose,
|
|
external: false,
|
|
fastSwitching: false,
|
|
fullMimeTypes: new Set(),
|
|
isAudioMuxedInVideo: false,
|
|
baseOriginalId: null,
|
|
};
|
|
this.setFullTypeForStream_(stream);
|
|
return stream;
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {string} mimeType
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {?Map<string, string>=} variables
|
|
* @return {Promise<{
|
|
* drmInfos: !Array<shaka.extern.DrmInfo>,
|
|
* keyIds: !Set<string>,
|
|
* encrypted: boolean,
|
|
* aesEncrypted: boolean,
|
|
* }>}
|
|
* @private
|
|
*/
|
|
async parseDrmInfo_(playlist, mimeType, getUris, variables) {
|
|
/** @type {!Map<!shaka.hls.Tag, ?shaka.media.InitSegmentReference>} */
|
|
const drmTagsMap = new Map();
|
|
if (!this.config_.ignoreDrmInfo && playlist.segments) {
|
|
for (const segment of playlist.segments) {
|
|
const segmentKeyTags = shaka.hls.Utils.filterTagsByName(segment.tags,
|
|
'EXT-X-KEY');
|
|
let initSegmentRef = null;
|
|
if (segmentKeyTags.length) {
|
|
initSegmentRef = this.getInitSegmentReference_(playlist,
|
|
segment.tags, getUris, variables);
|
|
for (const segmentKeyTag of segmentKeyTags) {
|
|
drmTagsMap.set(segmentKeyTag, initSegmentRef);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let encrypted = false;
|
|
let aesEncrypted = false;
|
|
|
|
/** @type {!Array<shaka.extern.DrmInfo>}*/
|
|
const drmInfos = [];
|
|
const keyIds = new Set();
|
|
|
|
for (const [key, value] of drmTagsMap) {
|
|
const drmTag = /** @type {!shaka.hls.Tag} */ (key);
|
|
const initSegmentRef =
|
|
/** @type {?shaka.media.InitSegmentReference} */ (value);
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
if (method != 'NONE') {
|
|
encrypted = true;
|
|
|
|
// According to the HLS spec, KEYFORMAT is optional and implicitly
|
|
// defaults to "identity".
|
|
// https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-4.4.4.4
|
|
const keyFormat =
|
|
drmTag.getAttributeValue('KEYFORMAT') || 'identity';
|
|
let drmInfo = null;
|
|
|
|
if (this.isAesMethod_(method)) {
|
|
// These keys are handled separately.
|
|
aesEncrypted = true;
|
|
continue;
|
|
} else if (keyFormat == 'identity') {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
drmInfo = await this.identityDrmParser_(
|
|
drmTag, mimeType, getUris, initSegmentRef, variables);
|
|
} else {
|
|
const drmParser =
|
|
this.keyFormatsToDrmParsers_.get(keyFormat);
|
|
drmInfo = drmParser ?
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await drmParser(drmTag, mimeType, initSegmentRef) :
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {drmInfos, keyIds, encrypted, aesEncrypted};
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {?Map<string, string>=} variables
|
|
* @return {!shaka.extern.aesKey}
|
|
* @private
|
|
*/
|
|
parseAESDrmTag_(drmTag, playlist, getUris, variables) {
|
|
// Check if the Web Crypto API is available.
|
|
if (!window.crypto || !window.crypto.subtle) {
|
|
shaka.log.alwaysWarn('Web Crypto API is not available to decrypt ' +
|
|
'AES. (Web Crypto only exists in secure origins like https)');
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.NO_WEB_CRYPTO_API);
|
|
}
|
|
|
|
// HLS RFC 8216 Section 5.2:
|
|
// An EXT-X-KEY tag with a KEYFORMAT of "identity" that does not have an IV
|
|
// attribute indicates that the Media Sequence Number is to be used as the
|
|
// IV when decrypting a Media Segment, by putting its big-endian binary
|
|
// representation into a 16-octet (128-bit) buffer and padding (on the left)
|
|
// with zeros.
|
|
let firstMediaSequenceNumber = 0;
|
|
let iv;
|
|
const ivHex = drmTag.getAttributeValue('IV', '');
|
|
if (!ivHex) {
|
|
// Media Sequence Number will be used as IV.
|
|
firstMediaSequenceNumber = shaka.hls.Utils.getFirstTagWithNameAsNumber(
|
|
playlist.tags, 'EXT-X-MEDIA-SEQUENCE', 0);
|
|
} else {
|
|
// Exclude 0x at the start of string.
|
|
iv = shaka.util.Uint8ArrayUtils.fromHex(ivHex.substr(2));
|
|
if (iv.byteLength != 16) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.AES_128_INVALID_IV_LENGTH);
|
|
}
|
|
}
|
|
|
|
const keyUris = shaka.hls.Utils.constructSegmentUris(
|
|
getUris(), drmTag.getRequiredAttrValue('URI'), variables);
|
|
const keyMapKey = keyUris.sort().join('');
|
|
const aesKeyInfoKey =
|
|
`${drmTag.toString()}-${firstMediaSequenceNumber}-${keyMapKey}`;
|
|
if (!this.aesKeyInfoMap_.has(aesKeyInfoKey)) {
|
|
// Default AES-128
|
|
const keyInfo = {
|
|
bitsKey: 128,
|
|
blockCipherMode: 'CBC',
|
|
iv,
|
|
firstMediaSequenceNumber,
|
|
};
|
|
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
switch (method) {
|
|
case 'AES-256':
|
|
keyInfo.bitsKey = 256;
|
|
break;
|
|
case 'AES-256-CTR':
|
|
keyInfo.bitsKey = 256;
|
|
keyInfo.blockCipherMode = 'CTR';
|
|
break;
|
|
}
|
|
|
|
// Don't download the key object until the segment is parsed, to avoid a
|
|
// startup delay for long manifests with lots of keys.
|
|
keyInfo.fetchKey = async () => {
|
|
if (!this.aesKeyMap_.has(keyMapKey)) {
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
|
|
const request = shaka.net.NetworkingEngine.makeRequest(
|
|
keyUris, this.config_.retryParameters);
|
|
const keyResponse = this.makeNetworkRequest_(request, requestType)
|
|
.promise;
|
|
this.aesKeyMap_.set(keyMapKey, keyResponse);
|
|
}
|
|
const keyResponse = await this.aesKeyMap_.get(keyMapKey);
|
|
|
|
// keyResponse.status is undefined when URI is "data:text/plain;base64,"
|
|
if (!keyResponse.data ||
|
|
keyResponse.data.byteLength != (keyInfo.bitsKey / 8)) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.AES_128_INVALID_KEY_LENGTH);
|
|
}
|
|
|
|
const algorithm = {
|
|
name: keyInfo.blockCipherMode == 'CTR' ? 'AES-CTR' : 'AES-CBC',
|
|
length: keyInfo.bitsKey,
|
|
};
|
|
keyInfo.cryptoKey = await window.crypto.subtle.importKey(
|
|
'raw', keyResponse.data, algorithm, true, ['decrypt']);
|
|
keyInfo.fetchKey = undefined; // No longer needed.
|
|
};
|
|
this.aesKeyInfoMap_.set(aesKeyInfoKey, keyInfo);
|
|
}
|
|
return this.aesKeyInfoMap_.get(aesKeyInfoKey);
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @private
|
|
*/
|
|
determineStartTime_(playlist) {
|
|
// If we already have a starttime we avoid processing this again.
|
|
if (this.startTime_ != null) {
|
|
return;
|
|
}
|
|
const startTimeTag =
|
|
shaka.hls.Utils.getFirstTagWithName(playlist.tags, 'EXT-X-START');
|
|
if (startTimeTag) {
|
|
this.startTime_ =
|
|
Number(startTimeTag.getRequiredAttrValue('TIME-OFFSET'));
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @private
|
|
*/
|
|
determineLastTargetDuration_(playlist) {
|
|
let lastTargetDuration = Infinity;
|
|
const segments = playlist.segments;
|
|
if (segments.length) {
|
|
let segmentIndex = segments.length - 1;
|
|
while (segmentIndex >= 0) {
|
|
const segment = segments[segmentIndex];
|
|
const extinfTag =
|
|
shaka.hls.Utils.getFirstTagWithName(segment.tags, 'EXTINF');
|
|
if (extinfTag) {
|
|
// The EXTINF tag format is '#EXTINF:<duration>,[<title>]'.
|
|
// We're interested in the duration part.
|
|
const extinfValues = extinfTag.value.split(',');
|
|
lastTargetDuration = Number(extinfValues[0]);
|
|
break;
|
|
}
|
|
segmentIndex--;
|
|
}
|
|
}
|
|
|
|
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');
|
|
if (partialTargetDurationTag) {
|
|
this.partialTargetDuration_ = Number(
|
|
partialTargetDurationTag.getRequiredAttrValue('PART-TARGET'));
|
|
}
|
|
// Get the server-recommended min distance from the live edge.
|
|
const serverControlTag = shaka.hls.Utils.getFirstTagWithName(
|
|
playlist.tags, 'EXT-X-SERVER-CONTROL');
|
|
// 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_ && this.partialTargetDuration_) {
|
|
// For low latency streaming, use the partial segment target duration.
|
|
if (this.lowLatencyByterangeOptimization_) {
|
|
// We always have at least 1 partial segment part, and most servers
|
|
// allow you to make a request with _HLS_msn=X&_HLS_part=0 with a
|
|
// distance of 4 partial segments. With this we ensure that we
|
|
// obtain the minimum latency in this type of case.
|
|
if (this.partialTargetDuration_ * 5 <= lastTargetDuration) {
|
|
this.lastTargetDuration_ = Math.min(
|
|
this.partialTargetDuration_, this.lastTargetDuration_);
|
|
} else {
|
|
this.lastTargetDuration_ = Math.min(
|
|
lastTargetDuration, this.lastTargetDuration_);
|
|
}
|
|
} else {
|
|
this.lastTargetDuration_ = Math.min(
|
|
this.partialTargetDuration_, this.lastTargetDuration_);
|
|
}
|
|
// Use 'PART-HOLD-BACK' as the presentation delay for low latency mode.
|
|
this.lowLatencyPresentationDelay_ = serverControlTag ? Number(
|
|
serverControlTag.getRequiredAttrValue('PART-HOLD-BACK')) : 0;
|
|
} else {
|
|
this.lastTargetDuration_ = Math.min(
|
|
lastTargetDuration, this.lastTargetDuration_);
|
|
// Use 'HOLD-BACK' as the presentation delay for default if defined.
|
|
const holdBack = serverControlTag ?
|
|
serverControlTag.getAttribute('HOLD-BACK') : null;
|
|
this.presentationDelay_ = holdBack ? Number(holdBack.value) : 0;
|
|
}
|
|
// 2. Update the longest target duration if need be to use as a
|
|
// presentation delay later.
|
|
this.maxTargetDuration_ = Math.max(
|
|
targetDuration, this.maxTargetDuration_);
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @private
|
|
*/
|
|
changePresentationTimelineToLive_(playlist) {
|
|
// 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 = 0;
|
|
if (this.config_.defaultPresentationDelay) {
|
|
presentationDelay = this.config_.defaultPresentationDelay;
|
|
} else if (this.lowLatencyPresentationDelay_) {
|
|
presentationDelay = this.lowLatencyPresentationDelay_;
|
|
} else if (this.presentationDelay_) {
|
|
presentationDelay = this.presentationDelay_;
|
|
} else {
|
|
const totalSegments = playlist.segments.length;
|
|
const delaySegments =
|
|
Math.min(totalSegments, this.config_.hls.liveSegmentsDelay);
|
|
for (let i = totalSegments - delaySegments; i < totalSegments; i++) {
|
|
const extinfTag = shaka.hls.Utils.getFirstTagWithName(
|
|
playlist.segments[i].tags, 'EXTINF');
|
|
if (extinfTag) {
|
|
const extinfValues = extinfTag.value.split(',');
|
|
const duration = Number(extinfValues[0]);
|
|
presentationDelay += duration;
|
|
} else {
|
|
presentationDelay += this.maxTargetDuration_;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.startTime_ && this.startTime_ < 0) {
|
|
presentationDelay = Math.min(-this.startTime_, presentationDelay);
|
|
this.startTime_ += presentationDelay;
|
|
}
|
|
|
|
this.presentationTimeline_.setPresentationStartTime(0);
|
|
this.presentationTimeline_.setDelay(presentationDelay);
|
|
this.presentationTimeline_.setStatic(false);
|
|
}
|
|
|
|
/**
|
|
* Get the InitSegmentReference for a segment if it has a EXT-X-MAP tag.
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {!Array<!shaka.hls.Tag>} tags Segment tags
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {?Map<string, string>=} variables
|
|
* @return {shaka.media.InitSegmentReference}
|
|
* @private
|
|
*/
|
|
getInitSegmentReference_(playlist, tags, getUris, 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 absoluteInitSegmentUris = shaka.hls.Utils.constructSegmentUris(
|
|
getUris(), verbatimInitSegmentUri, variables);
|
|
|
|
const mapTagKey = [
|
|
absoluteInitSegmentUris.toString(),
|
|
mapTag.getAttributeValue('BYTERANGE', ''),
|
|
].join('-');
|
|
if (!this.mapTagToInitSegmentRefMap_.has(mapTagKey)) {
|
|
/** @type {shaka.extern.aesKey|undefined} */
|
|
let aesKey = undefined;
|
|
let byteRangeTag = null;
|
|
let encrypted = false;
|
|
for (const tag of tags) {
|
|
if (tag.name == 'EXT-X-KEY') {
|
|
const method = tag.getRequiredAttrValue('METHOD');
|
|
if (this.isAesMethod_(method) && tag.id < mapTag.id) {
|
|
encrypted = false;
|
|
aesKey =
|
|
this.parseAESDrmTag_(tag, playlist, getUris, variables);
|
|
} else {
|
|
encrypted = method != 'NONE';
|
|
}
|
|
} else if (tag.name == 'EXT-X-BYTERANGE' && tag.id < mapTag.id) {
|
|
byteRangeTag = tag;
|
|
}
|
|
}
|
|
const initSegmentRef = this.createInitSegmentReference_(
|
|
absoluteInitSegmentUris, mapTag, byteRangeTag, aesKey, encrypted);
|
|
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 {!Array<string>} absoluteInitSegmentUris
|
|
* @param {!shaka.hls.Tag} mapTag EXT-X-MAP
|
|
* @param {shaka.hls.Tag=} byteRangeTag EXT-X-BYTERANGE
|
|
* @param {shaka.extern.aesKey=} aesKey
|
|
* @param {boolean=} encrypted
|
|
* @return {!shaka.media.InitSegmentReference}
|
|
* @private
|
|
*/
|
|
createInitSegmentReference_(absoluteInitSegmentUris, mapTag, byteRangeTag,
|
|
aesKey, encrypted) {
|
|
let startByte = 0;
|
|
let endByte = null;
|
|
let byterange = mapTag.getAttributeValue('BYTERANGE');
|
|
if (!byterange && byteRangeTag) {
|
|
byterange = byteRangeTag.value;
|
|
}
|
|
// 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;
|
|
|
|
if (aesKey) {
|
|
// MAP segment encrypted with method AES, when served with
|
|
// HTTP Range, has the unencrypted size specified in the range.
|
|
// See: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08#section-6.3.6
|
|
const length = (endByte + 1) - startByte;
|
|
if (length % 16) {
|
|
endByte += (16 - (length % 16));
|
|
}
|
|
}
|
|
}
|
|
|
|
const initSegmentRef = new shaka.media.InitSegmentReference(
|
|
() => absoluteInitSegmentUris,
|
|
startByte,
|
|
endByte,
|
|
/* mediaQuality= */ null,
|
|
/* timescale= */ null,
|
|
/* segmentData= */ null,
|
|
aesKey,
|
|
encrypted);
|
|
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 {!Map<string, string>} variables
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {string} type
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {string} mimeType
|
|
* @param {shaka.extern.aesKey=} aesKey
|
|
* @return {shaka.media.SegmentReference}
|
|
* @private
|
|
*/
|
|
createSegmentReference_(
|
|
initSegmentReference, previousReference, hlsSegment, startTime,
|
|
variables, playlist, type, getUris, mimeType, aesKey) {
|
|
const HlsParser = shaka.hls.HlsParser;
|
|
|
|
const tags = hlsSegment.tags;
|
|
const extinfTag =
|
|
shaka.hls.Utils.getFirstTagWithName(tags, 'EXTINF');
|
|
|
|
let endTime = 0;
|
|
let startByte = 0;
|
|
let endByte = null;
|
|
|
|
if (hlsSegment.partialSegments.length) {
|
|
this.manifest_.isLowLatency = true;
|
|
}
|
|
|
|
let syncTime = null;
|
|
if (previousReference && previousReference.syncTime) {
|
|
syncTime = previousReference.syncTime +
|
|
previousReference.endTime - previousReference.startTime;
|
|
} else if (!this.config_.hls.ignoreManifestProgramDateTime) {
|
|
const dateTimeTag =
|
|
shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-PROGRAM-DATE-TIME');
|
|
if (dateTimeTag && dateTimeTag.value) {
|
|
syncTime = shaka.util.TXml.parseDate(dateTimeTag.value);
|
|
goog.asserts.assert(syncTime != null,
|
|
'EXT-X-PROGRAM-DATE-TIME format not valid');
|
|
this.usesProgramDateTime_ = true;
|
|
}
|
|
}
|
|
|
|
let status = shaka.media.SegmentReference.Status.AVAILABLE;
|
|
if (shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-GAP')) {
|
|
this.manifest_.gapCount++;
|
|
status = shaka.media.SegmentReference.Status.MISSING;
|
|
}
|
|
|
|
if (!extinfTag) {
|
|
if (hlsSegment.partialSegments.length == 0) {
|
|
// EXTINF tag must be available if the segment has no partial segments.
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.HLS_REQUIRED_TAG_MISSING, 'EXTINF');
|
|
} else if (!this.lowLatencyMode_) {
|
|
// Without EXTINF and without low-latency mode, partial segments get
|
|
// ignored.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Create SegmentReferences for the partial segments.
|
|
let partialSegmentRefs = [];
|
|
|
|
// Optimization for LL-HLS with byterange
|
|
// More info in https://tinyurl.com/hls-open-byte-range
|
|
let segmentWithByteRangeOptimization = false;
|
|
let getUrisOptimization = null;
|
|
let somePartialSegmentWithGap = false;
|
|
let isPreloadSegment = false;
|
|
|
|
if (this.lowLatencyMode_ && hlsSegment.partialSegments.length) {
|
|
const byterangeOptimizationSupport =
|
|
initSegmentReference && window.ReadableStream &&
|
|
this.config_.hls.allowLowLatencyByteRangeOptimization;
|
|
|
|
let partialSyncTime = syncTime;
|
|
for (let i = 0; i < hlsSegment.partialSegments.length; i++) {
|
|
const item = hlsSegment.partialSegments[i];
|
|
const pPreviousReference = i == 0 ?
|
|
previousReference : partialSegmentRefs[partialSegmentRefs.length - 1];
|
|
const pStartTime = (i == 0) ? startTime : pPreviousReference.endTime;
|
|
|
|
// If DURATION is missing from this partial segment, use the target
|
|
// partial duration from the top of the playlist, which is a required
|
|
// attribute for content with partial segments.
|
|
const pDuration = Number(item.getAttributeValue('DURATION')) ||
|
|
this.partialTargetDuration_;
|
|
|
|
// If for some reason we have neither an explicit duration, nor a target
|
|
// partial duration, we should SKIP this partial segment to avoid
|
|
// duplicating content in the presentation timeline.
|
|
if (!pDuration) {
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
// A preload hinted partial segment may have byterange length info.
|
|
const pByterangeLength = item.getAttributeValue('BYTERANGE-LENGTH');
|
|
if (pByterangeLength) {
|
|
pEndByte = pStartByte + Number(pByterangeLength) - 1;
|
|
} else if (pStartByte) {
|
|
// If we have a non-zero start byte, but no end byte, follow the
|
|
// recommendation of https://tinyurl.com/hls-open-byte-range and
|
|
// set the end byte explicitly to a large integer.
|
|
pEndByte = Number.MAX_SAFE_INTEGER;
|
|
}
|
|
} else {
|
|
const pByterange = item.getAttributeValue('BYTERANGE');
|
|
[pStartByte, pEndByte] =
|
|
this.parseByteRange_(pPreviousReference, pByterange);
|
|
}
|
|
const pUri = item.getAttributeValue('URI');
|
|
if (!pUri) {
|
|
continue;
|
|
}
|
|
|
|
let partialStatus = shaka.media.SegmentReference.Status.AVAILABLE;
|
|
if (item.getAttributeValue('GAP') == 'YES') {
|
|
this.manifest_.gapCount++;
|
|
partialStatus = shaka.media.SegmentReference.Status.MISSING;
|
|
somePartialSegmentWithGap = true;
|
|
}
|
|
|
|
let uris = null;
|
|
const getPartialUris = () => {
|
|
if (uris == null) {
|
|
goog.asserts.assert(pUri, 'Partial uri should be defined!');
|
|
uris = shaka.hls.Utils.constructSegmentUris(
|
|
getUris(), pUri, variables);
|
|
}
|
|
return uris;
|
|
};
|
|
|
|
if (byterangeOptimizationSupport &&
|
|
pStartByte >= 0 && pEndByte != null) {
|
|
getUrisOptimization = getPartialUris;
|
|
segmentWithByteRangeOptimization = true;
|
|
}
|
|
|
|
const partial = new shaka.media.SegmentReference(
|
|
pStartTime,
|
|
pEndTime,
|
|
getPartialUris,
|
|
pStartByte,
|
|
pEndByte,
|
|
initSegmentReference,
|
|
/* timestampOffset= */ 0,
|
|
/* appendWindowStart= */ 0,
|
|
/* appendWindowEnd= */ Infinity,
|
|
/* partialReferences= */ [],
|
|
/* tilesLayout= */ '',
|
|
/* tileDuration= */ null,
|
|
partialSyncTime,
|
|
partialStatus,
|
|
aesKey);
|
|
if (item.name == 'EXT-X-PRELOAD-HINT') {
|
|
partial.markAsPreload();
|
|
isPreloadSegment = true;
|
|
}
|
|
// The spec doesn't say that we can assume INDEPENDENT=YES for the
|
|
// first partial segment. It does call the flag "optional", though, and
|
|
// that cases where there are no such flags on any partial segments, it
|
|
// is sensible to assume the first one is independent.
|
|
if (item.getAttributeValue('INDEPENDENT') != 'YES' && i > 0) {
|
|
partial.markAsNonIndependent();
|
|
}
|
|
|
|
let pMimeType = mimeType;
|
|
if (!pMimeType) {
|
|
if (partialSegmentRefs.length && partialSegmentRefs[0].mimeType) {
|
|
pMimeType = partialSegmentRefs[0].mimeType;
|
|
} else {
|
|
// Fallback for the very first partial segment if the parent
|
|
// segment's mimeType couldn't be determined.
|
|
if (pUri) {
|
|
const HlsParser = shaka.hls.HlsParser;
|
|
const extension = shaka.net.NetworkingUtils.getExtension(pUri);
|
|
const map = HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_.get(type);
|
|
pMimeType = map.get(extension) || null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pMimeType) {
|
|
partial.mimeType = pMimeType;
|
|
if (HlsParser.MIME_TYPES_WITHOUT_INIT_SEGMENT_.has(pMimeType)) {
|
|
partial.initSegmentReference = null;
|
|
}
|
|
}
|
|
partialSegmentRefs.push(partial);
|
|
|
|
if (partialSyncTime) {
|
|
partialSyncTime += pDuration;
|
|
}
|
|
} // for-loop of hlsSegment.partialSegments
|
|
}
|
|
|
|
// 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]);
|
|
// Skip segments without duration
|
|
if (duration == 0) {
|
|
return null;
|
|
}
|
|
endTime = startTime + duration;
|
|
} else if (partialSegmentRefs.length) {
|
|
endTime = partialSegmentRefs[partialSegmentRefs.length - 1].endTime;
|
|
} else {
|
|
// Skip segments without duration and without partial segments
|
|
return null;
|
|
}
|
|
|
|
if (segmentWithByteRangeOptimization) {
|
|
// We cannot optimize segments with gaps, or with a start byte that is
|
|
// not 0.
|
|
if (somePartialSegmentWithGap || partialSegmentRefs[0].startByte != 0) {
|
|
segmentWithByteRangeOptimization = false;
|
|
getUrisOptimization = null;
|
|
} else {
|
|
partialSegmentRefs = [];
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
let tilesLayout = '';
|
|
let tileDuration = null;
|
|
if (type == shaka.util.ManifestParserUtils.ContentType.IMAGE) {
|
|
// By default in HLS the tilesLayout is 1x1
|
|
tilesLayout = '1x1';
|
|
const tilesTag =
|
|
shaka.hls.Utils.getFirstTagWithName(tags, 'EXT-X-TILES');
|
|
if (tilesTag) {
|
|
tilesLayout = tilesTag.getRequiredAttrValue('LAYOUT');
|
|
const duration = tilesTag.getAttributeValue('DURATION');
|
|
if (duration) {
|
|
tileDuration = Number(duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
let uris = null;
|
|
const getSegmentUris = () => {
|
|
if (getUrisOptimization) {
|
|
return getUrisOptimization();
|
|
}
|
|
if (uris == null) {
|
|
uris = shaka.hls.Utils.constructSegmentUris(getUris(),
|
|
hlsSegment.verbatimSegmentUri, variables);
|
|
}
|
|
return uris || [];
|
|
};
|
|
|
|
const allPartialSegments = partialSegmentRefs.length > 0 &&
|
|
!!hlsSegment.verbatimSegmentUri;
|
|
|
|
const reference = new shaka.media.SegmentReference(
|
|
startTime,
|
|
endTime,
|
|
getSegmentUris,
|
|
startByte,
|
|
endByte,
|
|
initSegmentReference,
|
|
/* timestampOffset= */ 0,
|
|
/* appendWindowStart= */ 0,
|
|
/* appendWindowEnd= */ Infinity,
|
|
partialSegmentRefs,
|
|
tilesLayout,
|
|
tileDuration,
|
|
syncTime,
|
|
status,
|
|
aesKey,
|
|
allPartialSegments,
|
|
);
|
|
|
|
if (mimeType) {
|
|
reference.mimeType = mimeType;
|
|
if (HlsParser.MIME_TYPES_WITHOUT_INIT_SEGMENT_.has(mimeType)) {
|
|
reference.initSegmentReference = null;
|
|
}
|
|
}
|
|
|
|
if (segmentWithByteRangeOptimization) {
|
|
this.lowLatencyByterangeOptimization_ = true;
|
|
reference.markAsByterangeOptimization();
|
|
|
|
if (isPreloadSegment) {
|
|
reference.markAsPreload();
|
|
}
|
|
}
|
|
|
|
return reference;
|
|
}
|
|
|
|
|
|
/**
|
|
* 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];
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.hls.Tag>} tags
|
|
* @param {string} contentType
|
|
* @param {!Map<string, string>} variables
|
|
* @param {function(): !Array<string>} getUris
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async processDateRangeTags_(tags, contentType, variables, getUris) {
|
|
const ContentType = shaka.util.ManifestParserUtils.ContentType;
|
|
if (contentType != ContentType.VIDEO && contentType != ContentType.AUDIO) {
|
|
// DATE-RANGE should only appear in AUDIO or VIDEO playlists.
|
|
// We ignore those that appear in other playlists.
|
|
return;
|
|
}
|
|
const Utils = shaka.hls.Utils;
|
|
const initialProgramDateTime =
|
|
this.presentationTimeline_.getInitialProgramDateTime();
|
|
if (!initialProgramDateTime ||
|
|
this.ignoreManifestProgramDateTimeFor_(contentType)) {
|
|
return;
|
|
}
|
|
let dateRangeTags =
|
|
shaka.hls.Utils.filterTagsByName(tags, 'EXT-X-DATERANGE');
|
|
dateRangeTags = dateRangeTags.filter((tag) => {
|
|
return tag.getAttribute('START-DATE') != null;
|
|
}).sort((a, b) => {
|
|
const aStartDateValue = a.getRequiredAttrValue('START-DATE');
|
|
const bStartDateValue = b.getRequiredAttrValue('START-DATE');
|
|
if (aStartDateValue < bStartDateValue) {
|
|
return -1;
|
|
}
|
|
if (aStartDateValue > bStartDateValue) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
const promises = [];
|
|
for (let i = 0; i < dateRangeTags.length; i++) {
|
|
const tag = dateRangeTags[i];
|
|
try {
|
|
const startDateValue = tag.getRequiredAttrValue('START-DATE');
|
|
const startDate = shaka.util.TXml.parseDate(startDateValue);
|
|
if (isNaN(startDate)) {
|
|
// Invalid START-DATE
|
|
continue;
|
|
}
|
|
goog.asserts.assert(startDate != null,
|
|
'Start date should not be null!');
|
|
const startTime = Math.max(0, startDate - initialProgramDateTime);
|
|
|
|
let endTime = null;
|
|
const endDateValue = tag.getAttributeValue('END-DATE');
|
|
if (endDateValue) {
|
|
const endDate = shaka.util.TXml.parseDate(endDateValue);
|
|
if (!isNaN(endDate)) {
|
|
goog.asserts.assert(endDate != null,
|
|
'End date should not be null!');
|
|
endTime = endDate - initialProgramDateTime;
|
|
if (endTime < 0) {
|
|
// Date range in the past
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
if (endTime == null) {
|
|
const durationValue = tag.getAttributeValue('DURATION') ||
|
|
tag.getAttributeValue('PLANNED-DURATION');
|
|
if (durationValue) {
|
|
const duration = parseFloat(durationValue);
|
|
if (!isNaN(duration)) {
|
|
endTime = startTime + duration;
|
|
}
|
|
const realEndTime = startDate - initialProgramDateTime + duration;
|
|
if (realEndTime < 0) {
|
|
// Date range in the past
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
const type =
|
|
tag.getAttributeValue('CLASS') || 'com.apple.quicktime.HLS';
|
|
|
|
const endOnNext = tag.getAttributeValue('END-ON-NEXT') == 'YES';
|
|
if (endTime == null && endOnNext) {
|
|
for (let j = i + 1; j < dateRangeTags.length; j++) {
|
|
const otherDateRangeType =
|
|
dateRangeTags[j].getAttributeValue('CLASS') ||
|
|
'com.apple.quicktime.HLS';
|
|
if (type != otherDateRangeType) {
|
|
continue;
|
|
}
|
|
const otherDateRangeStartDateValue =
|
|
dateRangeTags[j].getRequiredAttrValue('START-DATE');
|
|
const otherDateRangeStartDate =
|
|
shaka.util.TXml.parseDate(otherDateRangeStartDateValue);
|
|
if (isNaN(otherDateRangeStartDate)) {
|
|
// Invalid START-DATE
|
|
continue;
|
|
}
|
|
if (otherDateRangeStartDate &&
|
|
otherDateRangeStartDate > startDate) {
|
|
endTime = Math.max(0,
|
|
otherDateRangeStartDate - initialProgramDateTime);
|
|
break;
|
|
}
|
|
}
|
|
if (endTime == null) {
|
|
// Since we cannot know when it ends, we omit it for now and in the
|
|
// future with an update we will be able to have more information.
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Exclude these attributes from the metadata since they already go into
|
|
// other fields (eg: startTime or endTime) or are not necessary..
|
|
const excludedAttributes = [
|
|
'CLASS',
|
|
'START-DATE',
|
|
'END-DATE',
|
|
'DURATION',
|
|
'END-ON-NEXT',
|
|
];
|
|
|
|
/* @type {!Array<shaka.extern.MetadataFrame>} */
|
|
const values = [];
|
|
for (const attribute of tag.attributes) {
|
|
if (excludedAttributes.includes(attribute.name)) {
|
|
continue;
|
|
}
|
|
let data = Utils.variableSubstitution(attribute.value, variables);
|
|
if (attribute.name == 'X-ASSET-URI' ||
|
|
attribute.name == 'X-ASSET-LIST') {
|
|
data = Utils.constructSegmentUris(
|
|
getUris(), attribute.value, variables)[0];
|
|
}
|
|
const metadataFrame = {
|
|
key: attribute.name,
|
|
description: '',
|
|
data,
|
|
mimeType: null,
|
|
pictureType: null,
|
|
};
|
|
values.push(metadataFrame);
|
|
}
|
|
|
|
// ID is always required. So we need more than 1 value.
|
|
if (values.length > 1) {
|
|
const promise = this.playerInterface_.onMetadata(
|
|
type, startTime, endTime, values);
|
|
if (promise) {
|
|
promises.push(promise);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
shaka.log.warning('Ignoring DATERANGE with errors', tag.toString());
|
|
}
|
|
}
|
|
if (promises.length) {
|
|
await Promise.all(promises);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses shaka.hls.Segment objects into shaka.media.SegmentReferences and
|
|
* get the bandwidth necessary for this segments If it's defined in the
|
|
* playlist.
|
|
*
|
|
* @param {!shaka.hls.Playlist} playlist
|
|
* @param {!Map<number, number>} mediaSequenceToStartTime
|
|
* @param {!Map<string, string>} variables
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {string} type
|
|
* @return {{segments: !Array<!shaka.media.SegmentReference>,
|
|
* bandwidth: (number|undefined)}}
|
|
* @private
|
|
*/
|
|
createSegments_(playlist, mediaSequenceToStartTime, variables,
|
|
getUris, type) {
|
|
/** @type {Array<!shaka.hls.Segment>} */
|
|
const hlsSegments = playlist.segments;
|
|
goog.asserts.assert(hlsSegments.length, 'Playlist should have segments!');
|
|
|
|
let currentInitSegmentRef = null;
|
|
let lastMapTagId = null;
|
|
let currentMimeType = null;
|
|
let lastUriExtension = null;
|
|
|
|
/** @type {shaka.extern.aesKey|undefined} */
|
|
let aesKey = undefined;
|
|
|
|
let discontinuitySequence = shaka.hls.Utils.getFirstTagWithNameAsNumber(
|
|
playlist.tags, 'EXT-X-DISCONTINUITY-SEQUENCE', -1);
|
|
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 = 0;
|
|
|
|
// For live stream, use the cached value in the mediaSequenceToStartTime
|
|
// map if available.
|
|
if (this.isLive_() && mediaSequenceToStartTime.has(position)) {
|
|
firstStartTime = mediaSequenceToStartTime.get(position);
|
|
}
|
|
// This is for recovering from disconnects.
|
|
if (firstStartTime === 0 &&
|
|
this.presentationType_ == shaka.hls.HlsParser.PresentationType_.LIVE &&
|
|
mediaSequenceToStartTime.size > 0 &&
|
|
!mediaSequenceToStartTime.has(position) &&
|
|
this.presentationTimeline_.getPresentationStartTime() != null) {
|
|
firstStartTime = this.presentationTimeline_.getSegmentAvailabilityStart();
|
|
}
|
|
|
|
/** @type {!Array<!shaka.media.SegmentReference>} */
|
|
const references = [];
|
|
|
|
let previousReference = null;
|
|
|
|
/** @type {!Array<{bitrate: number, duration: number}>} */
|
|
const bitrates = [];
|
|
|
|
const getMimeType = (uri) => {
|
|
const HlsParser = shaka.hls.HlsParser;
|
|
const extension = shaka.net.NetworkingUtils.getExtension(uri);
|
|
if (!extension) {
|
|
return null;
|
|
}
|
|
const map = HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_.get(type);
|
|
let mimeType = map.get(extension);
|
|
if (!mimeType) {
|
|
mimeType = HlsParser.RAW_FORMATS_TO_MIME_TYPES_.get(extension);
|
|
}
|
|
return mimeType;
|
|
};
|
|
|
|
for (let i = 0; i < hlsSegments.length; i++) {
|
|
const item = hlsSegments[i];
|
|
|
|
// Only look for a new init segment if the EXT-X-MAP tag has changed.
|
|
const mapTag = shaka.hls.Utils.getFirstTagWithName(item.tags,
|
|
'EXT-X-MAP');
|
|
if (mapTag && mapTag.id !== lastMapTagId) {
|
|
currentInitSegmentRef = this.getInitSegmentReference_(playlist,
|
|
item.tags, getUris, variables);
|
|
lastMapTagId = mapTag.id;
|
|
}
|
|
|
|
// Only re-evaluate the mimetype if the segment's file extension changes.
|
|
const uri = item.verbatimSegmentUri;
|
|
let extension = '';
|
|
if (uri) {
|
|
extension = shaka.net.NetworkingUtils.getExtension(uri);
|
|
}
|
|
|
|
if (extension !== lastUriExtension) {
|
|
currentMimeType = getMimeType(uri);
|
|
lastUriExtension = extension;
|
|
}
|
|
|
|
const startTime =
|
|
(i == 0) ? firstStartTime : previousReference.endTime;
|
|
position = mediaSequenceNumber + skippedSegments + i;
|
|
|
|
const discontinuityTag = shaka.hls.Utils.getFirstTagWithName(
|
|
item.tags, 'EXT-X-DISCONTINUITY');
|
|
if (discontinuityTag) {
|
|
discontinuitySequence++;
|
|
|
|
if (previousReference && previousReference.initSegmentReference) {
|
|
previousReference.initSegmentReference.boundaryEnd = startTime;
|
|
}
|
|
}
|
|
|
|
// Apply new AES tags as you see them, keeping a running total.
|
|
for (const drmTag of item.tags) {
|
|
if (drmTag.name == 'EXT-X-KEY') {
|
|
if (this.isAesMethod_(drmTag.getRequiredAttrValue('METHOD'))) {
|
|
aesKey =
|
|
this.parseAESDrmTag_(drmTag, playlist, getUris, variables);
|
|
} else {
|
|
aesKey = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
mediaSequenceToStartTime.set(position, startTime);
|
|
|
|
|
|
const reference = this.createSegmentReference_(
|
|
currentInitSegmentRef,
|
|
previousReference,
|
|
item,
|
|
startTime,
|
|
variables,
|
|
playlist,
|
|
type,
|
|
getUris,
|
|
currentMimeType,
|
|
aesKey);
|
|
|
|
if (reference) {
|
|
const bitrate = shaka.hls.Utils.getFirstTagWithNameAsNumber(
|
|
item.tags, 'EXT-X-BITRATE');
|
|
if (bitrate) {
|
|
bitrates.push({
|
|
bitrate,
|
|
duration: reference.endTime - reference.startTime,
|
|
});
|
|
reference.bandwidth = bitrate;
|
|
} else if (reference.endByte) {
|
|
const size = reference.getSize();
|
|
if (size) {
|
|
const duration = reference.endTime - reference.startTime;
|
|
const estimatedBitrate = size * 8 / duration / 1000;
|
|
bitrates.push({
|
|
bitrate: estimatedBitrate,
|
|
duration,
|
|
});
|
|
reference.bandwidth = estimatedBitrate;
|
|
}
|
|
} else if (bitrates.length) {
|
|
// It applies to every segment between it and the next EXT-X-BITRATE,
|
|
// so we use the latest bitrate value
|
|
const prevBitrate = bitrates.pop();
|
|
prevBitrate.duration += reference.endTime - reference.startTime;
|
|
bitrates.push(prevBitrate);
|
|
reference.bandwidth = prevBitrate.bitrate;
|
|
}
|
|
|
|
if (reference.bandwidth) {
|
|
for (const partial of reference.partialReferences) {
|
|
partial.bandwidth = reference.bandwidth;
|
|
}
|
|
}
|
|
|
|
previousReference = reference;
|
|
reference.discontinuitySequence = discontinuitySequence;
|
|
|
|
if (this.ignoreManifestProgramDateTimeFor_(type) &&
|
|
this.minSequenceNumber_ != null &&
|
|
position < this.minSequenceNumber_) {
|
|
// This segment is ignored as part of our fallback synchronization
|
|
// method.
|
|
} else {
|
|
references.push(reference);
|
|
}
|
|
}
|
|
}
|
|
|
|
let bandwidth = undefined;
|
|
if (bitrates.length) {
|
|
const duration = bitrates.reduce((sum, value) => {
|
|
return sum + value.duration;
|
|
}, 0);
|
|
bandwidth = Math.round(bitrates.reduce((sum, value) => {
|
|
return sum + value.bitrate * value.duration;
|
|
}, 0) / duration * 1000);
|
|
}
|
|
|
|
// If some segments have sync times, but not all, extrapolate the sync
|
|
// times of the ones with none.
|
|
const someSyncTime = references.some((ref) => ref.syncTime != null);
|
|
if (someSyncTime) {
|
|
for (let i = 0; i < references.length; i++) {
|
|
const reference = references[i];
|
|
if (reference.syncTime != null) {
|
|
// No need to extrapolate.
|
|
continue;
|
|
}
|
|
// Find the nearest segment with syncTime, in either direction.
|
|
// This looks forward and backward simultaneously, keeping track of what
|
|
// to offset the syncTime it finds by as it goes.
|
|
let forwardAdd = 0;
|
|
let forwardI = i;
|
|
/**
|
|
* Look forwards one reference at a time, summing all durations as we
|
|
* go, until we find a reference with a syncTime to use as a basis.
|
|
* This DOES count the original reference, but DOESN'T count the first
|
|
* reference with a syncTime (as we approach it from behind).
|
|
* @return {?number}
|
|
*/
|
|
const lookForward = () => {
|
|
const other = references[forwardI];
|
|
if (other) {
|
|
if (other.syncTime != null) {
|
|
return other.syncTime + forwardAdd;
|
|
}
|
|
forwardAdd -= other.endTime - other.startTime;
|
|
forwardI += 1;
|
|
}
|
|
return null;
|
|
};
|
|
let backwardAdd = 0;
|
|
let backwardI = i;
|
|
/**
|
|
* Look backwards one reference at a time, summing all durations as we
|
|
* go, until we find a reference with a syncTime to use as a basis.
|
|
* This DOESN'T count the original reference, but DOES count the first
|
|
* reference with a syncTime (as we approach it from ahead).
|
|
* @return {?number}
|
|
*/
|
|
const lookBackward = () => {
|
|
const other = references[backwardI];
|
|
if (other) {
|
|
if (other != reference) {
|
|
backwardAdd += other.endTime - other.startTime;
|
|
}
|
|
if (other.syncTime != null) {
|
|
return other.syncTime + backwardAdd;
|
|
}
|
|
backwardI -= 1;
|
|
}
|
|
return null;
|
|
};
|
|
while (reference.syncTime == null) {
|
|
reference.syncTime = lookBackward();
|
|
if (reference.syncTime == null) {
|
|
reference.syncTime = lookForward();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Split the sync times properly among partial segments.
|
|
if (someSyncTime) {
|
|
for (const reference of references) {
|
|
let syncTime = reference.syncTime;
|
|
for (const partial of reference.partialReferences) {
|
|
partial.syncTime = syncTime;
|
|
syncTime += partial.endTime - partial.startTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
// lowestSyncTime is a value from a previous playlist update. Use it to
|
|
// set reference start times. If this is the first playlist parse, we will
|
|
// skip this step, and wait until we have sync time across stream types.
|
|
const lowestSyncTime = this.lowestSyncTime_;
|
|
if (someSyncTime && lowestSyncTime != Infinity) {
|
|
if (!this.ignoreManifestProgramDateTimeFor_(type)) {
|
|
for (const reference of references) {
|
|
reference.syncAgainst(lowestSyncTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
segments: references,
|
|
bandwidth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempts to guess stream's mime type based on content type and URI.
|
|
*
|
|
* @param {string} contentType
|
|
* @param {string} codecs
|
|
* @return {?string}
|
|
* @private
|
|
*/
|
|
guessMimeTypeBeforeLoading_(contentType, codecs) {
|
|
if (contentType == shaka.util.ManifestParserUtils.ContentType.TEXT) {
|
|
if (codecs == 'vtt' || codecs == 'wvtt') {
|
|
// If codecs is 'vtt', it's WebVTT.
|
|
return 'text/vtt';
|
|
} else if (codecs && codecs !== '') {
|
|
// Otherwise, assume MP4-embedded text, since text-based formats tend
|
|
// not to have a codecs string at all.
|
|
return 'application/mp4';
|
|
}
|
|
}
|
|
|
|
if (contentType == shaka.util.ManifestParserUtils.ContentType.IMAGE) {
|
|
if (!codecs || codecs == 'jpeg') {
|
|
return 'image/jpeg';
|
|
}
|
|
}
|
|
|
|
if (contentType == shaka.util.ManifestParserUtils.ContentType.AUDIO) {
|
|
// See: https://bugs.chromium.org/p/chromium/issues/detail?id=489520
|
|
if (codecs == 'mp4a.40.34') {
|
|
return 'audio/mpeg';
|
|
}
|
|
}
|
|
|
|
if (codecs == 'mjpg') {
|
|
return 'application/mp4';
|
|
}
|
|
|
|
// Not enough information to guess from the content type and codecs.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get a fallback mime type for the content. Used if all the better methods
|
|
* for determining the mime type have failed.
|
|
*
|
|
* @param {string} contentType
|
|
* @return {string}
|
|
* @private
|
|
*/
|
|
guessMimeTypeFallback_(contentType) {
|
|
if (contentType == shaka.util.ManifestParserUtils.ContentType.TEXT) {
|
|
// If there was no codecs string and no content-type, assume HLS text
|
|
// streams are WebVTT.
|
|
return 'text/vtt';
|
|
}
|
|
// If the HLS content is lacking in both MIME type metadata and
|
|
// segment file extensions, we fall back to assuming it's MP4.
|
|
const map =
|
|
shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_.get(contentType);
|
|
return map.get('mp4');
|
|
}
|
|
|
|
/**
|
|
* @param {!Array<!shaka.media.SegmentReference>} segments
|
|
* @return {{segment: !shaka.media.SegmentReference, segmentIndex: number}}
|
|
* @private
|
|
*/
|
|
getAvailableSegment_(segments) {
|
|
goog.asserts.assert(segments.length, 'Should have segments!');
|
|
|
|
// If you wait long enough, requesting the first segment can fail
|
|
// because it has fallen off the left edge of DVR, so to be safer,
|
|
// let's request the middle segment.
|
|
let segmentIndex = this.isLive_() ?
|
|
Math.trunc((segments.length - 1) / 2) : 0;
|
|
let segment = segments[segmentIndex];
|
|
while (segment.getStatus() == shaka.media.SegmentReference.Status.MISSING &&
|
|
(segmentIndex + 1) < segments.length) {
|
|
segmentIndex ++;
|
|
segment = segments[segmentIndex];
|
|
}
|
|
return {segment, segmentIndex};
|
|
}
|
|
|
|
/**
|
|
* Attempts to guess stream's mime type.
|
|
*
|
|
* @param {string} contentType
|
|
* @param {string} codecs
|
|
* @param {!Array<!shaka.media.SegmentReference>} segments
|
|
* @return {!Promise<string>}
|
|
* @private
|
|
*/
|
|
async guessMimeType_(contentType, codecs, segments) {
|
|
const HlsParser = shaka.hls.HlsParser;
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
|
|
|
|
const {segment} = this.getAvailableSegment_(segments);
|
|
|
|
if (segment.status == shaka.media.SegmentReference.Status.MISSING) {
|
|
return this.guessMimeTypeFallback_(contentType);
|
|
}
|
|
|
|
let mimeType;
|
|
|
|
const segmentUris = segment.getUris();
|
|
|
|
const extension = shaka.net.NetworkingUtils.getExtension(segmentUris[0]);
|
|
if (extension) {
|
|
const map = HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_.get(contentType);
|
|
|
|
let mimeType = map.get(extension);
|
|
if (mimeType) {
|
|
return mimeType;
|
|
}
|
|
|
|
mimeType = HlsParser.RAW_FORMATS_TO_MIME_TYPES_.get(extension);
|
|
if (mimeType) {
|
|
return mimeType;
|
|
}
|
|
}
|
|
|
|
// The extension map didn't work, so guess based on codecs.
|
|
mimeType = this.guessMimeTypeBeforeLoading_(contentType, codecs);
|
|
if (mimeType) {
|
|
return mimeType;
|
|
}
|
|
|
|
// If unable to guess mime type, request a segment and try getting it
|
|
// from the response.
|
|
let contentMimeType;
|
|
const type = shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_SEGMENT;
|
|
const headRequest = shaka.net.NetworkingEngine.makeRequest(
|
|
segmentUris, this.config_.retryParameters);
|
|
let response;
|
|
try {
|
|
headRequest.method = 'HEAD';
|
|
response = await this.makeNetworkRequest_(
|
|
headRequest, requestType, {type}).promise;
|
|
|
|
contentMimeType = response.headers['content-type'];
|
|
} catch (error) {
|
|
if (error &&
|
|
(error.code == shaka.util.Error.Code.HTTP_ERROR ||
|
|
error.code == shaka.util.Error.Code.BAD_HTTP_STATUS)) {
|
|
headRequest.method = 'GET';
|
|
if (this.config_.hls.allowRangeRequestsToGuessMimeType) {
|
|
// Only requesting first byte
|
|
headRequest.headers['Range'] = 'bytes=0-0';
|
|
}
|
|
response = await this.makeNetworkRequest_(
|
|
headRequest, requestType, {type}).promise;
|
|
|
|
contentMimeType = response.headers['content-type'];
|
|
}
|
|
}
|
|
|
|
if (contentMimeType) {
|
|
// Split the MIME type in case the server sent additional parameters.
|
|
mimeType = contentMimeType.toLowerCase().split(';')[0];
|
|
if (mimeType == 'application/octet-stream') {
|
|
if (!response.data.byteLength) {
|
|
headRequest.method = 'GET';
|
|
response = await this.makeNetworkRequest_(
|
|
headRequest, requestType, {type}).promise;
|
|
}
|
|
if (shaka.util.TsParser.probe(
|
|
shaka.util.BufferUtils.toUint8(response.data))) {
|
|
mimeType = 'video/mp2t';
|
|
}
|
|
}
|
|
if (mimeType != 'application/octet-stream') {
|
|
return mimeType;
|
|
}
|
|
}
|
|
|
|
return this.guessMimeTypeFallback_(contentType);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param {?string} videoRange
|
|
* @param {?string} videoLayout
|
|
* @param {?string} colorGamut
|
|
* @private
|
|
*/
|
|
addVideoAttributes_(stream, width, height, frameRate, videoRange,
|
|
videoLayout, colorGamut) {
|
|
if (stream) {
|
|
stream.width = Number(width) || undefined;
|
|
stream.height = Number(height) || undefined;
|
|
stream.frameRate = Number(frameRate) || undefined;
|
|
stream.hdr = videoRange || undefined;
|
|
stream.videoLayout = videoLayout || undefined;
|
|
stream.colorGamut = colorGamut || undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes a network request for the manifest and returns a Promise
|
|
* with the resulting data.
|
|
*
|
|
* @param {!Array<string>} uris
|
|
* @param {boolean=} isPlaylist
|
|
* @return {!shaka.net.NetworkingEngine.PendingRequest}
|
|
* @private
|
|
*/
|
|
requestManifest_(uris, isPlaylist) {
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.MANIFEST;
|
|
|
|
const request = shaka.net.NetworkingEngine.makeRequest(
|
|
uris, this.config_.retryParameters);
|
|
|
|
const type = isPlaylist ?
|
|
shaka.net.NetworkingEngine.AdvancedRequestType.MEDIA_PLAYLIST :
|
|
shaka.net.NetworkingEngine.AdvancedRequestType.MASTER_PLAYLIST;
|
|
return this.makeNetworkRequest_(request, requestType, {type});
|
|
}
|
|
|
|
/**
|
|
* 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.getUpdatePlaylistDelay_() > 0,
|
|
'We should only call |onUpdate_| when we are suppose to be updating.');
|
|
|
|
// Detect a call to stop()
|
|
if (!this.playerInterface_) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const startTime = Date.now();
|
|
await this.update();
|
|
|
|
// Keep track of how long the longest manifest update took.
|
|
const endTime = Date.now();
|
|
|
|
// This may have converted to VOD, in which case we stop updating.
|
|
if (this.isLive_()) {
|
|
const updateDuration = (endTime - startTime) / 1000.0;
|
|
this.averageUpdateDuration_.sample(1, updateDuration);
|
|
const delay = this.config_.updatePeriod > 0 ?
|
|
this.config_.updatePeriod : this.getUpdatePlaylistDelay_();
|
|
const finalDelay = Math.max(0,
|
|
delay - this.averageUpdateDuration_.getEstimate());
|
|
this.updatePlaylistTimer_.tickAfter(/* seconds= */ finalDelay);
|
|
}
|
|
} 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');
|
|
|
|
if (this.config_.raiseFatalErrorOnManifestUpdateRequestFailure) {
|
|
this.playerInterface_.onError(error);
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Detect a call to stop()
|
|
if (!this.playerInterface_) {
|
|
return;
|
|
}
|
|
|
|
this.playerInterface_.onManifestUpdated();
|
|
}
|
|
|
|
|
|
/**
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
isLive_() {
|
|
const PresentationType = shaka.hls.HlsParser.PresentationType_;
|
|
return this.presentationType_ != PresentationType.VOD;
|
|
}
|
|
|
|
|
|
/**
|
|
* @return {number}
|
|
* @private
|
|
*/
|
|
getUpdatePlaylistDelay_() {
|
|
// 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.
|
|
return this.lastTargetDuration_;
|
|
}
|
|
|
|
|
|
/**
|
|
* @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
|
|
* @param {shaka.extern.RequestContext=} context
|
|
* @return {!shaka.net.NetworkingEngine.PendingRequest}
|
|
* @private
|
|
*/
|
|
makeNetworkRequest_(request, type, context) {
|
|
if (!this.operationManager_) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.PLAYER,
|
|
shaka.util.Error.Code.OPERATION_ABORTED);
|
|
}
|
|
|
|
if (!context) {
|
|
context = {};
|
|
}
|
|
context.isPreload = this.isPreloadFn_();
|
|
const op = this.playerInterface_.networkingEngine.request(
|
|
type, request, context);
|
|
this.operationManager_.manage(op);
|
|
|
|
return op;
|
|
}
|
|
|
|
/**
|
|
* @param {string} method
|
|
* @return {boolean}
|
|
* @private
|
|
*/
|
|
isAesMethod_(method) {
|
|
return method == 'AES-128' ||
|
|
method == 'AES-256' ||
|
|
method == 'AES-256-CTR';
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {string} mimeType
|
|
* @param {?shaka.media.InitSegmentReference} initSegmentRef
|
|
* @return {!Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
async fairplayDrmParser_(drmTag, mimeType, initSegmentRef) {
|
|
if (mimeType == 'video/mp2t') {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED);
|
|
}
|
|
|
|
if (shaka.drm.DrmUtils.isMediaKeysPolyfilled('apple')) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code
|
|
.HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED);
|
|
}
|
|
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR'];
|
|
if (!VALID_METHODS.includes(method)) {
|
|
shaka.log.error('FairPlay in HLS is only supported with [',
|
|
VALID_METHODS.join(', '), '], not', method);
|
|
return null;
|
|
}
|
|
|
|
let encryptionScheme = 'cenc';
|
|
if (method == 'SAMPLE-AES') {
|
|
// It should be 'cbcs-1-9' but Safari doesn't support it.
|
|
// See: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/Modules/encryptedmedia/MediaKeyEncryptionScheme.idl
|
|
encryptionScheme = 'cbcs';
|
|
}
|
|
|
|
const keySystemUri = drmTag.getRequiredAttrValue('URI');
|
|
|
|
/*
|
|
* Even if we're not able to construct initData through the HLS tag, adding
|
|
* a DRMInfo will allow DRM Engine to request a media key system access
|
|
* with the correct keySystem and initDataType
|
|
*/
|
|
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
|
|
'com.apple.fps',
|
|
encryptionScheme,
|
|
/* initData= */ [
|
|
{initDataType: 'sinf', initData: new Uint8Array(0), keyId: null},
|
|
],
|
|
keySystemUri);
|
|
|
|
let keyId = shaka.drm.FairPlay.defaultGetKeyId(keySystemUri);
|
|
if (!keyId && initSegmentRef) {
|
|
keyId = await this.getKeyIdFromInitSegment_(initSegmentRef);
|
|
}
|
|
if (keyId) {
|
|
drmInfo.keyIds.add(keyId);
|
|
}
|
|
|
|
return drmInfo;
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {string} mimeType
|
|
* @param {?shaka.media.InitSegmentReference} initSegmentRef
|
|
* @return {!Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
widevineDrmParser_(drmTag, mimeType, initSegmentRef) {
|
|
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 Promise.resolve(null);
|
|
}
|
|
|
|
let encryptionScheme = 'cenc';
|
|
if (method == 'SAMPLE-AES') {
|
|
encryptionScheme = 'cbcs';
|
|
}
|
|
|
|
const uri = drmTag.getRequiredAttrValue('URI');
|
|
const parsedData = shaka.net.DataUriPlugin.parseRaw(uri.split('?')[0]);
|
|
|
|
const psshKey = uri.split('?')[0];
|
|
if (!this.psshToInitData_.has(psshKey)) {
|
|
// The data encoded in the URI is a PSSH box to be used as init data.
|
|
const initData = shaka.util.BufferUtils.toUint8(parsedData.data);
|
|
this.psshToInitData_.set(psshKey, initData);
|
|
}
|
|
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
|
|
'com.widevine.alpha',
|
|
encryptionScheme,
|
|
/* initData= */ [
|
|
{initDataType: 'cenc', initData: this.psshToInitData_.get(psshKey)},
|
|
],
|
|
/* keySystemUri= */ undefined,
|
|
/* mediaTypeList= */ parsedData.typeInfoList);
|
|
|
|
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.add(keyIdLowerCase.substr(2));
|
|
}
|
|
return Promise.resolve(drmInfo);
|
|
}
|
|
|
|
/**
|
|
* See: https://docs.microsoft.com/en-us/playready/packaging/mp4-based-formats-supported-by-playready-clients?tabs=case4
|
|
*
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {string} mimeType
|
|
* @param {?shaka.media.InitSegmentReference} initSegmentRef
|
|
* @return {!Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
playreadyDrmParser_(drmTag, mimeType, initSegmentRef) {
|
|
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 Promise.resolve(null);
|
|
}
|
|
|
|
let encryptionScheme = 'cenc';
|
|
if (method == 'SAMPLE-AES') {
|
|
encryptionScheme = 'cbcs';
|
|
}
|
|
|
|
const uri = drmTag.getRequiredAttrValue('URI');
|
|
const parsedData = shaka.net.DataUriPlugin.parseRaw(uri.split('?')[0]);
|
|
|
|
// 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 keyIds = new Set();
|
|
const psshVersion = 0;
|
|
const pssh =
|
|
shaka.util.Pssh.createPssh(data, systemId, keyIds, psshVersion);
|
|
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
|
|
/* keySystem= */ 'com.microsoft.playready',
|
|
encryptionScheme,
|
|
/* initData= */ [
|
|
{initDataType: 'cenc', initData: pssh},
|
|
],
|
|
/* keySystemUri= */ undefined,
|
|
/* mediaTypeList= */ parsedData.typeInfoList);
|
|
|
|
const input = shaka.util.TXml.parseXmlString([
|
|
'<PLAYREADY>',
|
|
shaka.util.Uint8ArrayUtils.toBase64(data),
|
|
'</PLAYREADY>',
|
|
].join('\n'));
|
|
if (input) {
|
|
drmInfo.licenseServerUri = shaka.drm.PlayReady.getLicenseUrl(input);
|
|
}
|
|
|
|
return Promise.resolve(drmInfo);
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {string} mimeType
|
|
* @param {?shaka.media.InitSegmentReference} initSegmentRef
|
|
* @return {!Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
wiseplayDrmParser_(drmTag, mimeType, initSegmentRef) {
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR'];
|
|
if (!VALID_METHODS.includes(method)) {
|
|
shaka.log.error('WisePlay in HLS is only supported with [',
|
|
VALID_METHODS.join(', '), '], not', method);
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
let encryptionScheme = 'cenc';
|
|
if (method == 'SAMPLE-AES') {
|
|
encryptionScheme = 'cbcs';
|
|
}
|
|
|
|
const uri = drmTag.getRequiredAttrValue('URI');
|
|
const parsedData = shaka.net.DataUriPlugin.parseRaw(uri.split('?')[0]);
|
|
|
|
const psshKey = uri.split('?')[0];
|
|
if (!this.psshToInitData_.has(psshKey)) {
|
|
// The data encoded in the URI is a PSSH box to be used as init data.
|
|
const initData = shaka.util.BufferUtils.toUint8(parsedData.data);
|
|
this.psshToInitData_.set(psshKey, initData);
|
|
}
|
|
const drmInfo = shaka.util.ManifestParserUtils.createDrmInfo(
|
|
/* keySystem= */ 'com.huawei.wiseplay',
|
|
encryptionScheme,
|
|
/* initData= */ [
|
|
{initDataType: 'cenc', initData: this.psshToInitData_.get(psshKey)},
|
|
],
|
|
/* keySystemUri= */ undefined,
|
|
/* mediaTypeList= */ parsedData.typeInfoList);
|
|
|
|
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.add(keyIdLowerCase.substr(2));
|
|
}
|
|
return Promise.resolve(drmInfo);
|
|
}
|
|
|
|
/**
|
|
* See: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis-11#section-5.1
|
|
*
|
|
* @param {!shaka.hls.Tag} drmTag
|
|
* @param {string} mimeType
|
|
* @param {function(): !Array<string>} getUris
|
|
* @param {?shaka.media.InitSegmentReference} initSegmentRef
|
|
* @param {?Map<string, string>=} variables
|
|
* @return {!Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
async identityDrmParser_(drmTag, mimeType, getUris, initSegmentRef,
|
|
variables) {
|
|
if (mimeType == 'video/mp2t') {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code.HLS_MSE_ENCRYPTED_MP2T_NOT_SUPPORTED);
|
|
}
|
|
|
|
if (shaka.drm.DrmUtils.isMediaKeysPolyfilled('apple')) {
|
|
throw new shaka.util.Error(
|
|
shaka.util.Error.Severity.CRITICAL,
|
|
shaka.util.Error.Category.MANIFEST,
|
|
shaka.util.Error.Code
|
|
.HLS_MSE_ENCRYPTED_LEGACY_APPLE_MEDIA_KEYS_NOT_SUPPORTED);
|
|
}
|
|
|
|
const method = drmTag.getRequiredAttrValue('METHOD');
|
|
const VALID_METHODS = ['SAMPLE-AES', 'SAMPLE-AES-CTR'];
|
|
if (!VALID_METHODS.includes(method)) {
|
|
shaka.log.error('Identity (ClearKey) in HLS is only supported with [',
|
|
VALID_METHODS.join(', '), '], not', method);
|
|
return null;
|
|
}
|
|
|
|
const keyUris = shaka.hls.Utils.constructSegmentUris(
|
|
getUris(), drmTag.getRequiredAttrValue('URI'), variables);
|
|
|
|
let key;
|
|
if (keyUris[0].startsWith('data:text/plain;base64,')) {
|
|
key = shaka.util.Uint8ArrayUtils.toHex(
|
|
shaka.util.Uint8ArrayUtils.fromBase64(
|
|
keyUris[0].split('data:text/plain;base64,').pop()));
|
|
} else {
|
|
const keyMapKey = keyUris.sort().join('');
|
|
if (!this.identityKeyMap_.has(keyMapKey)) {
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.KEY;
|
|
const request = shaka.net.NetworkingEngine.makeRequest(
|
|
keyUris, this.config_.retryParameters);
|
|
const keyResponse = this.makeNetworkRequest_(request, requestType)
|
|
.promise;
|
|
this.identityKeyMap_.set(keyMapKey, keyResponse);
|
|
}
|
|
const keyResponse = await this.identityKeyMap_.get(keyMapKey);
|
|
key = shaka.util.Uint8ArrayUtils.toHex(keyResponse.data);
|
|
}
|
|
|
|
// NOTE: The ClearKey CDM requires a key-id to key mapping. HLS doesn't
|
|
// provide a key ID anywhere. So although we could use the 'URI' attribute
|
|
// to fetch the actual 16-byte key, without a key ID, we can't provide this
|
|
// automatically to the ClearKey CDM. By default we assume that keyId is 0,
|
|
// but we will try to get key ID from Init Segment.
|
|
// If the application want override this behavior will have to use
|
|
// player.configure('drm.clearKeys', { ... }) to provide the key IDs
|
|
// and keys or player.configure('drm.servers.org\.w3\.clearkey', ...) to
|
|
// provide a ClearKey license server URI.
|
|
let keyId = '00000000000000000000000000000000';
|
|
|
|
if (initSegmentRef) {
|
|
const defaultKID = await this.getKeyIdFromInitSegment_(initSegmentRef);
|
|
if (defaultKID) {
|
|
keyId = defaultKID;
|
|
}
|
|
}
|
|
|
|
const clearkeys = new Map();
|
|
clearkeys.set(keyId, key);
|
|
|
|
let encryptionScheme = 'cenc';
|
|
if (method == 'SAMPLE-AES') {
|
|
encryptionScheme = 'cbcs';
|
|
}
|
|
|
|
return shaka.util.ManifestParserUtils.createDrmInfoFromClearKeys(
|
|
clearkeys, encryptionScheme);
|
|
}
|
|
|
|
/**
|
|
* @param {!shaka.media.InitSegmentReference} initSegmentRef
|
|
* @return {!Promise<?string>}
|
|
* @private
|
|
*/
|
|
async getKeyIdFromInitSegment_(initSegmentRef) {
|
|
let keyId = null;
|
|
if (this.initSegmentToKidMap_.has(initSegmentRef)) {
|
|
keyId = this.initSegmentToKidMap_.get(initSegmentRef);
|
|
} else {
|
|
const initSegmentRequest = shaka.util.Networking.createSegmentRequest(
|
|
initSegmentRef.getUris(),
|
|
initSegmentRef.getStartByte(),
|
|
initSegmentRef.getEndByte(),
|
|
this.config_.retryParameters);
|
|
const requestType = shaka.net.NetworkingEngine.RequestType.SEGMENT;
|
|
const initType =
|
|
shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT;
|
|
const initResponse = await this.makeNetworkRequest_(
|
|
initSegmentRequest, requestType, {type: initType}).promise;
|
|
|
|
initSegmentRef.setSegmentData(initResponse.data);
|
|
|
|
keyId = shaka.media.SegmentUtils.getDefaultKID(
|
|
initResponse.data);
|
|
this.initSegmentToKidMap_.set(initSegmentRef, keyId);
|
|
}
|
|
return keyId;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* @typedef {{
|
|
* stream: !shaka.extern.Stream,
|
|
* type: string,
|
|
* redirectUris: !Array<string>,
|
|
* getUris: function():!Array<string>,
|
|
* minTimestamp: number,
|
|
* maxTimestamp: number,
|
|
* mediaSequenceToStartTime: !Map<number, number>,
|
|
* canSkipSegments: boolean,
|
|
* canBlockReload: boolean,
|
|
* hasEndList: boolean,
|
|
* firstSequenceNumber: number,
|
|
* nextMediaSequence: number,
|
|
* nextPart: number,
|
|
* }}
|
|
*
|
|
* @description
|
|
* Contains a stream and information about it.
|
|
*
|
|
* @property {!shaka.extern.Stream} stream
|
|
* The Stream itself.
|
|
* @property {string} type
|
|
* The type value. Could be 'video', 'audio', 'text', or 'image'.
|
|
* @property {!Array<string>} redirectUris
|
|
* The redirect URIs.
|
|
* @property {function():!Array<string>} getUris
|
|
* The verbatim media playlist URIs, as it appeared in the master playlist.
|
|
* @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.
|
|
* Only used for VOD content.
|
|
* @property {boolean} canSkipSegments
|
|
* True if the server supports delta playlist updates, and we can send a
|
|
* request for a playlist that can skip older media segments.
|
|
* @property {boolean} canBlockReload
|
|
* True if the server supports blocking playlist reload, and we can send a
|
|
* request for a playlist that can block reload until some segments are
|
|
* present.
|
|
* @property {boolean} hasEndList
|
|
* True if the stream has an EXT-X-ENDLIST tag.
|
|
* @property {number} firstSequenceNumber
|
|
* The sequence number of the first reference. Only calculated if needed.
|
|
* @property {number} nextMediaSequence
|
|
* The next media sequence.
|
|
* @property {number} nextPart
|
|
* The next part.
|
|
*/
|
|
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;
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.RAW_FORMATS_TO_MIME_TYPES_ = new Map()
|
|
.set('aac', 'audio/aac')
|
|
.set('ac3', 'audio/ac3')
|
|
.set('ec3', 'audio/ec3')
|
|
.set('mp3', 'audio/mpeg');
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.AUDIO_EXTENSIONS_TO_MIME_TYPES_ = new Map()
|
|
.set('mp4', 'audio/mp4')
|
|
.set('mp4a', 'audio/mp4')
|
|
.set('m4s', 'audio/mp4')
|
|
.set('m4i', 'audio/mp4')
|
|
.set('m4a', 'audio/mp4')
|
|
.set('m4f', 'audio/mp4')
|
|
.set('cmfa', 'audio/mp4')
|
|
// MPEG2-TS also uses video/ for audio: https://bit.ly/TsMse
|
|
.set('ts', 'video/mp2t')
|
|
.set('tsa', 'video/mp2t');
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.VIDEO_EXTENSIONS_TO_MIME_TYPES_ = new Map()
|
|
.set('mp4', 'video/mp4')
|
|
.set('mp4v', 'video/mp4')
|
|
.set('m4s', 'video/mp4')
|
|
.set('m4i', 'video/mp4')
|
|
.set('m4v', 'video/mp4')
|
|
.set('m4f', 'video/mp4')
|
|
.set('cmfv', 'video/mp4')
|
|
.set('ts', 'video/mp2t')
|
|
.set('tsv', 'video/mp2t');
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_ = new Map()
|
|
.set('mp4', 'application/mp4')
|
|
.set('m4s', 'application/mp4')
|
|
.set('m4i', 'application/mp4')
|
|
.set('m4f', 'application/mp4')
|
|
.set('cmft', 'application/mp4')
|
|
.set('vtt', 'text/vtt')
|
|
.set('webvtt', 'text/vtt')
|
|
.set('ttml', 'application/ttml+xml');
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.IMAGE_EXTENSIONS_TO_MIME_TYPES_ = new Map()
|
|
.set('jpg', 'image/jpeg')
|
|
.set('png', 'image/png')
|
|
.set('svg', 'image/svg+xml')
|
|
.set('webp', 'image/webp')
|
|
.set('avif', 'image/avif');
|
|
|
|
|
|
/**
|
|
* @const {!Map<string, !Map<string, string>>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.EXTENSION_MAP_BY_CONTENT_TYPE_ = new Map()
|
|
.set('audio', shaka.hls.HlsParser.AUDIO_EXTENSIONS_TO_MIME_TYPES_)
|
|
.set('video', shaka.hls.HlsParser.VIDEO_EXTENSIONS_TO_MIME_TYPES_)
|
|
.set('text', shaka.hls.HlsParser.TEXT_EXTENSIONS_TO_MIME_TYPES_)
|
|
.set('image', shaka.hls.HlsParser.IMAGE_EXTENSIONS_TO_MIME_TYPES_);
|
|
|
|
|
|
/**
|
|
* MIME types without init segment.
|
|
*
|
|
* @const {!Set<string>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.MIME_TYPES_WITHOUT_INIT_SEGMENT_ = new Set([
|
|
'video/mp2t',
|
|
// Containerless types
|
|
...shaka.util.MimeUtils.RAW_FORMATS,
|
|
]);
|
|
|
|
|
|
/**
|
|
* @typedef {function(!shaka.hls.Tag,
|
|
* string,
|
|
* ?shaka.media.InitSegmentReference):
|
|
* !Promise<?shaka.extern.DrmInfo>}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.DrmParser_;
|
|
|
|
|
|
/**
|
|
* @enum {string}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.PresentationType_ = {
|
|
VOD: 'VOD',
|
|
EVENT: 'EVENT',
|
|
LIVE: 'LIVE',
|
|
};
|
|
|
|
|
|
/**
|
|
* @const {string}
|
|
* @private
|
|
*/
|
|
shaka.hls.HlsParser.FAKE_MUXED_URL_ = 'shaka://hls-muxed';
|
|
|
|
|
|
shaka.media.ManifestParser.registerParserByMime(
|
|
'application/x-mpegurl', () => new shaka.hls.HlsParser());
|
|
shaka.media.ManifestParser.registerParserByMime(
|
|
'application/vnd.apple.mpegurl', () => new shaka.hls.HlsParser());
|