Merge branch 'm3u-loader-correction' into m3u-live

This commit is contained in:
Trek H 2020-01-21 13:26:15 +10:30
commit 3ae4670e49
1 changed files with 159 additions and 346 deletions

View File

@ -1,58 +1,111 @@
/** /*
* PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models. AUTHOR
* Trek Hopton <trek@ausocean.org>
* Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
* LICENSE
* Uses loader(s) set in config to do actual internal loading of resource tasks. This file is Copyright (C) 2020 the Australian Ocean Lab (AusOcean)
*
* @module It is free software: you can redistribute it and/or modify them
* under the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or (at your
option) any later version.
It is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details.
You should have received a copy of the GNU General Public License in gpl.txt.
If not, see http://www.gnu.org/licenses.
For hls.js Copyright notice and license, see LICENSE file.
*/ */
import Event from '../events'; import { PlaylistContextType, PlaylistLevelType } from '../types/loader.js';
import EventHandler from '../event-handler'; import Event from '../events.js';
import { ErrorTypes, ErrorDetails } from '../errors'; import EventHandler from '../event-handler.js';
import { logger } from '../utils/logger'; import M3U8Parser from './m3u8-parser.js';
import { Loader, PlaylistContextType, PlaylistLoaderContext, PlaylistLevelType, LoaderCallbacks, LoaderResponse, LoaderStats, LoaderConfiguration } from '../types/loader';
import MP4Demuxer from '../demux/mp4demuxer';
import M3U8Parser from './m3u8-parser';
import { AudioGroup } from '../types/media-playlist';
const { performance } = window; const { performance } = window;
/**
* @constructor
*/
class PlaylistLoader extends EventHandler { class PlaylistLoader extends EventHandler {
private loaders: Partial<Record<PlaylistContextType, Loader<PlaylistLoaderContext>>> = {};
/**
* @constructs
* @param {Hls} hls
*/
constructor(hls) { constructor(hls) {
super(hls, super(hls,
Event.MANIFEST_LOADING, Event.MANIFEST_LOADING,
Event.LEVEL_LOADING, Event.LEVEL_LOADING,
Event.AUDIO_TRACK_LOADING, Event.AUDIO_TRACK_LOADING,
Event.SUBTITLE_TRACK_LOADING); Event.SUBTITLE_TRACK_LOADING);
this.hls = hls;
this.loaders = {};
} }
/** /**
* @param {PlaylistContextType} type * @param {PlaylistContextType} type
* @returns {boolean} * @returns {boolean}
*/ */
static canHaveQualityLevels (type: PlaylistContextType): boolean { static canHaveQualityLevels(type) {
return (type !== PlaylistContextType.AUDIO_TRACK && return (type !== PlaylistContextType.AUDIO_TRACK &&
type !== PlaylistContextType.SUBTITLE_TRACK); type !== PlaylistContextType.SUBTITLE_TRACK);
} }
onManifestLoading(data) {
this.load({
url: data.url,
type: PlaylistContextType.MANIFEST,
level: 0,
id: null,
responseType: 'text'
});
}
// param 1 -> data: { url: string; level: number | null; id: number | null; }
onLevelLoading(data) {
this.load({
url: data.url,
type: PlaylistContextType.LEVEL,
level: data.level,
id: data.id,
responseType: 'text'
});
}
onAudioTrackLoading(data) {
this.load({
url: data.url,
type: PlaylistContextType.AUDIO_TRACK,
level: null,
id: data.id,
responseType: 'text'
});
}
onSubtitleTrackLoading(data) {
this.load({
url: data.url,
type: PlaylistContextType.SUBTITLE_TRACK,
level: null,
id: data.id,
responseType: 'text'
});
}
static getResponseUrl(response, context) {
let url = response.url;
// responseURL not supported on some browsers (it is used to detect URL redirection)
// data-uri mode also not supported (but no need to detect redirection)
if (url === undefined || url.indexOf('data:') === 0) {
// fallback to initial URL
url = context.url;
}
return url;
}
/** /**
* Map context.type to LevelType * Map context.type to LevelType
* @param {PlaylistLoaderContext} context * @param {PlaylistLoaderContext} context
* @returns {LevelType} * @returns {LevelType}
*/ */
static mapContextToLevelType (context: PlaylistLoaderContext): PlaylistLevelType { static mapContextToLevelType(context) {
const { type } = context; const { type } = context;
switch (type) { switch (type) {
@ -65,15 +118,8 @@ class PlaylistLoader extends EventHandler {
} }
} }
static getResponseUrl (response: LoaderResponse, context: PlaylistLoaderContext): string { getInternalLoader(context) {
let url = response.url; return this.loaders[context.type];
// responseURL not supported on some browsers (it is used to detect URL redirection)
// data-uri mode also not supported (but no need to detect redirection)
if (url === undefined || url.indexOf('data:') === 0) {
// fallback to initial URL
url = context.url;
}
return url;
} }
/** /**
@ -82,7 +128,7 @@ class PlaylistLoader extends EventHandler {
* @param {PlaylistLoaderContext} context * @param {PlaylistLoaderContext} context
* @returns {Loader} or other compatible configured overload * @returns {Loader} or other compatible configured overload
*/ */
createInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> { createInternalLoader(context) {
const config = this.hls.config; const config = this.hls.config;
const PLoader = config.pLoader; const PLoader = config.pLoader;
const Loader = config.loader; const Loader = config.loader;
@ -98,98 +144,30 @@ class PlaylistLoader extends EventHandler {
return loader; return loader;
} }
getInternalLoader (context: PlaylistLoaderContext): Loader<PlaylistLoaderContext> | undefined { resetInternalLoader(contextType) {
return this.loaders[context.type];
}
resetInternalLoader (contextType: PlaylistContextType) {
if (this.loaders[contextType]) { if (this.loaders[contextType]) {
delete this.loaders[contextType]; delete this.loaders[contextType];
} }
} }
/** load(context) {
* Call `destroy` on all internal loader instances mapped (one per context type)
*/
destroyInternalLoaders () {
for (let contextType in this.loaders) {
let loader = this.loaders[contextType];
if (loader) {
loader.destroy();
}
this.resetInternalLoader(contextType as PlaylistContextType);
}
}
destroy () {
this.destroyInternalLoaders();
super.destroy();
}
onManifestLoading (data: { url: string; }) {
this.load({
url: data.url,
type: PlaylistContextType.MANIFEST,
level: 0,
id: null,
responseType: 'text'
});
}
onLevelLoading (data: { url: string; level: number | null; id: number | null; }) {
this.load({
url: data.url,
type: PlaylistContextType.LEVEL,
level: data.level,
id: data.id,
responseType: 'text'
});
}
onAudioTrackLoading (data: { url: string; id: number | null; }) {
this.load({
url: data.url,
type: PlaylistContextType.AUDIO_TRACK,
level: null,
id: data.id,
responseType: 'text'
});
}
onSubtitleTrackLoading (data: { url: string; id: number | null; }) {
this.load({
url: data.url,
type: PlaylistContextType.SUBTITLE_TRACK,
level: null,
id: data.id,
responseType: 'text'
});
}
load (context: PlaylistLoaderContext): boolean {
const config = this.hls.config; const config = this.hls.config;
logger.debug(`Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);
// Check if a loader for this context already exists // Check if a loader for this context already exists
let loader = this.getInternalLoader(context); let loader = this.getInternalLoader(context);
if (loader) { if (loader) {
const loaderContext = loader.context; const loaderContext = loader.context;
if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap if (loaderContext && loaderContext.url === context.url) { // same URL can't overlap
logger.trace('playlist request ongoing');
return false; return false;
} else { } else {
logger.warn(`aborting previous loader for type: ${context.type}`);
loader.abort(); loader.abort();
} }
} }
let maxRetry: number; let maxRetry;
let timeout: number; let timeout;
let retryDelay: number; let retryDelay;
let maxRetryDelay: number; let maxRetryDelay;
// apply different configs for retries depending on // apply different configs for retries depending on
// context (manifest, level, audio/subs playlist) // context (manifest, level, audio/subs playlist)
@ -218,26 +196,25 @@ class PlaylistLoader extends EventHandler {
loader = this.createInternalLoader(context); loader = this.createInternalLoader(context);
const loaderConfig: LoaderConfiguration = { const loaderConfig = {
timeout, timeout,
maxRetry, maxRetry,
retryDelay, retryDelay,
maxRetryDelay maxRetryDelay
}; };
const loaderCallbacks: LoaderCallbacks<PlaylistLoaderContext> = { const loaderCallbacks = {
onSuccess: this.loadsuccess.bind(this), onSuccess: this.loadsuccess.bind(this),
onError: this.loaderror.bind(this), onError: this.loaderror.bind(this),
onTimeout: this.loadtimeout.bind(this) onTimeout: this.loadtimeout.bind(this)
}; };
logger.debug(`Calling internal loader delegate for URL: ${context.url}`);
loader.load(context, loaderConfig, loaderCallbacks); loader.load(context, loaderConfig, loaderCallbacks);
return true; return true;
} }
loadsuccess (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown = null) { loadsuccess(response, stats, context, networkDetails = null) {
if (context.isSidxRequest) { if (context.isSidxRequest) {
this._handleSidxRequest(response, context); this._handleSidxRequest(response, context);
this._handlePlaylistLoaded(response, stats, context, networkDetails); this._handlePlaylistLoaded(response, stats, context, networkDetails);
@ -252,11 +229,10 @@ class PlaylistLoader extends EventHandler {
const string = response.data; const string = response.data;
stats.tload = performance.now(); stats.tload = performance.now();
// stats.mtime = new Date(target.getResponseHeader('Last-Modified'));
// Validate if it is an M3U8 at all // Validate if it is an M3U8 at all
if (string.indexOf('#EXTM3U') !== 0) { if (string.indexOf('#EXTM3U') !== 0) {
this._handleManifestParsingError(response, context, 'no EXTM3U delimiter', networkDetails); console.error("no EXTM3U delimiter");
return; return;
} }
@ -264,77 +240,21 @@ class PlaylistLoader extends EventHandler {
if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) { if (string.indexOf('#EXTINF:') > 0 || string.indexOf('#EXT-X-TARGETDURATION:') > 0) {
this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails); this._handleTrackOrLevelPlaylist(response, stats, context, networkDetails);
} else { } else {
this._handleMasterPlaylist(response, stats, context, networkDetails); console.log("handling of master playlists is not implemented");
} // this._handleMasterPlaylist(response, stats, context, networkDetails);
} }
loaderror (response: LoaderResponse, context: PlaylistLoaderContext, networkDetails = null) {
this._handleNetworkError(context, networkDetails, false, response);
} }
loadtimeout (stats: LoaderStats, context: PlaylistLoaderContext, networkDetails = null) { loaderror(response, context, networkDetails = null) {
this._handleNetworkError(context, networkDetails, true); console.error("network error while loading", response);
} }
// TODO(typescript-config): networkDetails can currently be a XHR or Fetch impl, loadtimeout(stats, context, networkDetails = null) {
// but with custom loaders it could be generic investigate this further when config is typed console.error("network timeout while loading", stats);
_handleMasterPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
const hls = this.hls;
const string = response.data as string;
const url = PlaylistLoader.getResponseUrl(response, context);
const levels = M3U8Parser.parseMasterPlaylist(string, url);
if (!levels.length) {
this._handleManifestParsingError(response, context, 'no level found in manifest', networkDetails);
return;
} }
// multi level playlist, parse level info _handleTrackOrLevelPlaylist(response, stats, context, networkDetails) {
const audioGroups: Array<AudioGroup> = levels.map(level => ({
id: level.attrs.AUDIO,
codec: level.audioCodec
}));
const audioTracks = M3U8Parser.parseMasterPlaylistMedia(string, url, 'AUDIO', audioGroups);
const subtitles = M3U8Parser.parseMasterPlaylistMedia(string, url, 'SUBTITLES');
if (audioTracks.length) {
// check if we have found an audio track embedded in main playlist (audio track without URI attribute)
let embeddedAudioFound = false;
audioTracks.forEach(audioTrack => {
if (!audioTrack.url) {
embeddedAudioFound = true;
}
});
// if no embedded audio track defined, but audio codec signaled in quality level,
// we need to signal this main audio track this could happen with playlists with
// alt audio rendition in which quality levels (main)
// contains both audio+video. but with mixed audio track not signaled
if (embeddedAudioFound === false && levels[0].audioCodec && !levels[0].attrs.AUDIO) {
logger.log('audio codec signaled in quality level, but no embedded audio track signaled, create one');
audioTracks.unshift({
type: 'main',
name: 'main',
default: false,
autoselect: false,
forced: false,
id: -1
});
}
}
hls.trigger(Event.MANIFEST_LOADED, {
levels,
audioTracks,
subtitles,
url,
stats,
networkDetails
});
}
_handleTrackOrLevelPlaylist (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
const hls = this.hls; const hls = this.hls;
const { id, level, type } = context; const { id, level, type } = context;
@ -342,15 +262,15 @@ class PlaylistLoader extends EventHandler {
const url = PlaylistLoader.getResponseUrl(response, context); const url = PlaylistLoader.getResponseUrl(response, context);
// if the values are null, they will result in the else conditional // if the values are null, they will result in the else conditional
const levelUrlId = Number.isFinite(id as number) ? id as number : 0; const levelUrlId = Number.isFinite(id) ? id : 0;
const levelId = Number.isFinite(level as number) ? level as number : levelUrlId; const levelId = Number.isFinite(level) ? level : levelUrlId;
const levelType = PlaylistLoader.mapContextToLevelType(context); const levelType = PlaylistLoader.mapContextToLevelType(context);
const levelDetails = M3U8Parser.parseLevelPlaylist(response.data as string, url, levelId, levelType, levelUrlId); const levelDetails = M3U8Parser.parseLevelPlaylist(response.data, url, levelId, levelType, levelUrlId);
// set stats on level structure // set stats on level structure
// TODO(jstackhouse): why? mixing concerns, is it just treated as value bag? // TODO(jstackhouse): why? mixing concerns, is it just treated as value bag?
(levelDetails as any).tload = stats.tload; (levelDetails).tload = stats.tload;
// We have done our first request (Manifest-type) and receive // We have done our first request (Manifest-type) and receive
// not a master playlist but a chunk-list (track/level) // not a master playlist but a chunk-list (track/level)
@ -374,124 +294,17 @@ class PlaylistLoader extends EventHandler {
// save parsing time // save parsing time
stats.tparsed = performance.now(); stats.tparsed = performance.now();
// in case we need SIDX ranges
// return early after calling load for
// the SIDX box.
if (levelDetails.needSidxRanges) {
const sidxUrl = levelDetails.initSegment.url;
this.load({
url: sidxUrl,
isSidxRequest: true,
type,
level,
levelDetails,
id,
rangeStart: 0,
rangeEnd: 2048,
responseType: 'arraybuffer'
});
return;
}
// extend the context with the new levelDetails property // extend the context with the new levelDetails property
context.levelDetails = levelDetails; context.levelDetails = levelDetails;
this._handlePlaylistLoaded(response, stats, context, networkDetails); this._handlePlaylistLoaded(response, stats, context, networkDetails);
} }
_handleSidxRequest (response: LoaderResponse, context: PlaylistLoaderContext) { _handlePlaylistLoaded(response, stats, context, networkDetails) {
if (typeof response.data === 'string') {
throw new Error('sidx request must be made with responseType of array buffer');
}
const sidxInfo = MP4Demuxer.parseSegmentIndex(new Uint8Array(response.data));
// if provided fragment does not contain sidx, early return
if (!sidxInfo) {
return;
}
const sidxReferences = sidxInfo.references;
const levelDetails = context.levelDetails;
sidxReferences.forEach((segmentRef, index) => {
const segRefInfo = segmentRef.info;
if (!levelDetails) {
return;
}
const frag = levelDetails.fragments[index];
if (frag.byteRange.length === 0) {
frag.setByteRange(String(1 + segRefInfo.end - segRefInfo.start) + '@' + String(segRefInfo.start));
}
});
if (levelDetails) {
levelDetails.initSegment.setByteRange(String(sidxInfo.moovEndOffset) + '@0');
}
}
_handleManifestParsingError (response: LoaderResponse, context: PlaylistLoaderContext, reason: string, networkDetails: unknown) {
this.hls.trigger(Event.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_PARSING_ERROR,
fatal: true,
url: response.url,
reason,
networkDetails
});
}
_handleNetworkError (context: PlaylistLoaderContext, networkDetails: unknown, timeout: boolean = false, response: LoaderResponse | null = null) {
logger.info(`A network error occured while loading a ${context.type}-type playlist`);
let details;
let fatal;
const loader = this.getInternalLoader(context);
switch (context.type) {
case PlaylistContextType.MANIFEST:
details = (timeout ? ErrorDetails.MANIFEST_LOAD_TIMEOUT : ErrorDetails.MANIFEST_LOAD_ERROR);
fatal = true;
break;
case PlaylistContextType.LEVEL:
details = (timeout ? ErrorDetails.LEVEL_LOAD_TIMEOUT : ErrorDetails.LEVEL_LOAD_ERROR);
fatal = false;
break;
case PlaylistContextType.AUDIO_TRACK:
details = (timeout ? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT : ErrorDetails.AUDIO_TRACK_LOAD_ERROR);
fatal = false;
break;
default:
// details = ...?
fatal = false;
}
if (loader) {
loader.abort();
this.resetInternalLoader(context.type);
}
// TODO(typescript-events): when error events are handled, type this
let errorData: any = {
type: ErrorTypes.NETWORK_ERROR,
details,
fatal,
url: context.url,
loader,
context,
networkDetails
};
if (response) {
errorData.response = response;
}
this.hls.trigger(Event.ERROR, errorData);
}
_handlePlaylistLoaded (response: LoaderResponse, stats: LoaderStats, context: PlaylistLoaderContext, networkDetails: unknown) {
const { type, level, id, levelDetails } = context; const { type, level, id, levelDetails } = context;
if (!levelDetails || !levelDetails.targetduration) { if (!levelDetails || !levelDetails.targetduration) {
this._handleManifestParsingError(response, context, 'invalid target duration', networkDetails); console.error("manifest parsing error");
return; return;
} }