Source: playlist-loader.js

/**
 * @file playlist-loader.js
 *
 * A state machine that manages the loading, caching, and updating of
 * M3U8 playlists.
 *
 */
import resolveUrl from './resolve-url';
import {mergeOptions} from 'video.js';
import Stream from './stream';
import m3u8 from 'm3u8-parser';
import window from 'global/window';

/**
  * Returns a new array of segments that is the result of merging
  * properties from an older list of segments onto an updated
  * list. No properties on the updated playlist will be overridden.
  *
  * @param {Array} original the outdated list of segments
  * @param {Array} update the updated list of segments
  * @param {Number=} offset the index of the first update
  * segment in the original segment list. For non-live playlists,
  * this should always be zero and does not need to be
  * specified. For live playlists, it should be the difference
  * between the media sequence numbers in the original and updated
  * playlists.
  * @return a list of merged segment objects
  */
const updateSegments = function(original, update, offset) {
  let result = update.slice();
  let length;
  let i;

  offset = offset || 0;
  length = Math.min(original.length, update.length + offset);

  for (i = offset; i < length; i++) {
    result[i - offset] = mergeOptions(original[i], result[i - offset]);
  }
  return result;
};

/**
  * Returns a new master playlist that is the result of merging an
  * updated media playlist into the original version. If the
  * updated media playlist does not match any of the playlist
  * entries in the original master playlist, null is returned.
  *
  * @param {Object} master a parsed master M3U8 object
  * @param {Object} media a parsed media M3U8 object
  * @return {Object} a new object that represents the original
  * master playlist with the updated media playlist merged in, or
  * null if the merge produced no change.
  */
const updateMaster = function(master, media) {
  let changed = false;
  let result = mergeOptions(master, {});
  let i = master.playlists.length;
  let playlist;
  let segment;
  let j;

  while (i--) {
    playlist = result.playlists[i];
    if (playlist.uri === media.uri) {
      // consider the playlist unchanged if the number of segments
      // are equal and the media sequence number is unchanged
      if (playlist.segments &&
          media.segments &&
          playlist.segments.length === media.segments.length &&
          playlist.mediaSequence === media.mediaSequence) {
        continue;
      }

      result.playlists[i] = mergeOptions(playlist, media);
      result.playlists[media.uri] = result.playlists[i];

      // if the update could overlap existing segment information,
      // merge the two lists
      if (playlist.segments) {
        result.playlists[i].segments = updateSegments(
          playlist.segments,
          media.segments,
          media.mediaSequence - playlist.mediaSequence
        );
      }
      // resolve any missing segment and key URIs
      j = 0;
      if (result.playlists[i].segments) {
        j = result.playlists[i].segments.length;
      }
      while (j--) {
        segment = result.playlists[i].segments[j];
        if (!segment.resolvedUri) {
          segment.resolvedUri = resolveUrl(playlist.resolvedUri, segment.uri);
        }
        if (segment.key && !segment.key.resolvedUri) {
          segment.key.resolvedUri = resolveUrl(playlist.resolvedUri, segment.key.uri);
        }
        if (segment.map && !segment.map.resolvedUri) {
          segment.map.resolvedUri = resolveUrl(playlist.resolvedUri, segment.map.uri);
        }
      }
      changed = true;
    }
  }
  return changed ? result : null;
};

/**
 * Load a playlist from a remote loacation
 *
 * @class PlaylistLoader
 * @extends Stream
 * @param {String} srcUrl the url to start with
 * @param {Boolean} withCredentials the withCredentials xhr option
 * @constructor
 */
const PlaylistLoader = function(srcUrl, hls, withCredentials) {
  /* eslint-disable consistent-this */
  let loader = this;
  /* eslint-enable consistent-this */
  let dispose;
  let mediaUpdateTimeout;
  let request;
  let playlistRequestError;
  let haveMetadata;

  PlaylistLoader.prototype.constructor.call(this);

  this.hls_ = hls;

  // a flag that disables "expired time"-tracking this setting has
  // no effect when not playing a live stream
  this.trackExpiredTime_ = false;

  if (!srcUrl) {
    throw new Error('A non-empty playlist URL is required');
  }

  playlistRequestError = function(xhr, url, startingState) {
    loader.setBandwidth(request || xhr);

    // any in-flight request is now finished
    request = null;

    if (startingState) {
      loader.state = startingState;
    }

    loader.error = {
      playlist: loader.master.playlists[url],
      status: xhr.status,
      message: 'HLS playlist request error at URL: ' + url,
      responseText: xhr.responseText,
      code: (xhr.status >= 500) ? 4 : 2
    };

    loader.trigger('error');
  };

  // update the playlist loader's state in response to a new or
  // updated playlist.
  haveMetadata = function(xhr, url) {
    let parser;
    let refreshDelay;
    let update;

    loader.setBandwidth(request || xhr);

    // any in-flight request is now finished
    request = null;

    loader.state = 'HAVE_METADATA';

    parser = new m3u8.Parser();
    parser.push(xhr.responseText);
    parser.end();
    parser.manifest.uri = url;

    // merge this playlist into the master
    update = updateMaster(loader.master, parser.manifest);
    refreshDelay = (parser.manifest.targetDuration || 10) * 1000;
    loader.targetDuration = parser.manifest.targetDuration;
    if (update) {
      loader.master = update;
      loader.updateMediaPlaylist_(parser.manifest);
    } else {
      // if the playlist is unchanged since the last reload,
      // try again after half the target duration
      refreshDelay /= 2;
    }

    // refresh live playlists after a target duration passes
    if (!loader.media().endList) {
      window.clearTimeout(mediaUpdateTimeout);
      mediaUpdateTimeout = window.setTimeout(function() {
        loader.trigger('mediaupdatetimeout');
      }, refreshDelay);
    }

    loader.trigger('loadedplaylist');
  };

  // initialize the loader state
  loader.state = 'HAVE_NOTHING';

  // track the time that has expired from the live window
  // this allows the seekable start range to be calculated even if
  // all segments with timing information have expired
  this.expired_ = 0;

  // capture the prototype dispose function
  dispose = this.dispose;

   /**
    * Abort any outstanding work and clean up.
    */
  loader.dispose = function() {
    loader.stopRequest();
    window.clearTimeout(mediaUpdateTimeout);
    dispose.call(this);
  };

  loader.stopRequest = () => {
    if (request) {
      let oldRequest = request;

      request = null;
      oldRequest.onreadystatechange = null;
      oldRequest.abort();
    }
  };

  /**
   * Returns the number of enabled playlists on the master playlist object
   *
   * @return {Number} number of eneabled playlists
   */
  loader.enabledPlaylists_ = function() {
    return loader.master.playlists.filter((element, index, array) => {
      return !element.excludeUntil || element.excludeUntil <= Date.now();
    }).length;
  };

  /**
   * Returns whether the current playlist is the lowest rendition
   *
   * @return {Boolean} true if on lowest rendition
   */
  loader.isLowestEnabledRendition_ = function() {
    let media = loader.media();

    if (!media || !media.attributes) {
      return false;
    }

    let currentBandwidth = loader.media().attributes.BANDWIDTH || 0;

    return !(loader.master.playlists.filter((element, index, array) => {
      let enabled = typeof element.excludeUntil === 'undefined' ||
                      element.excludeUntil <= Date.now();

      if (!enabled) {
        return false;
      }

      let item = element.attributes.BANDWIDTH;

      return item <= currentBandwidth;

    }).length > 1);
  };

   /**
    * When called without any arguments, returns the currently
    * active media playlist. When called with a single argument,
    * triggers the playlist loader to asynchronously switch to the
    * specified media playlist. Calling this method while the
    * loader is in the HAVE_NOTHING causes an error to be emitted
    * but otherwise has no effect.
    *
    * @param {Object=} playlis tthe parsed media playlist
    * object to switch to
    * @return {Playlist} the current loaded media
    */
  loader.media = function(playlist) {
    let startingState = loader.state;
    let mediaChange;

    // getter
    if (!playlist) {
      return loader.media_;
    }

    // setter
    if (loader.state === 'HAVE_NOTHING') {
      throw new Error('Cannot switch media playlist from ' + loader.state);
    }

    // find the playlist object if the target playlist has been
    // specified by URI
    if (typeof playlist === 'string') {
      if (!loader.master.playlists[playlist]) {
        throw new Error('Unknown playlist URI: ' + playlist);
      }
      playlist = loader.master.playlists[playlist];
    }

    mediaChange = !loader.media_ || playlist.uri !== loader.media_.uri;

    // switch to fully loaded playlists immediately
    if (loader.master.playlists[playlist.uri].endList) {
      // abort outstanding playlist requests
      if (request) {
        request.onreadystatechange = null;
        request.abort();
        request = null;
      }
      loader.state = 'HAVE_METADATA';
      loader.media_ = playlist;

      // trigger media change if the active media has been updated
      if (mediaChange) {
        loader.trigger('mediachanging');
        loader.trigger('mediachange');
      }
      return;
    }

    // switching to the active playlist is a no-op
    if (!mediaChange) {
      return;
    }

    loader.state = 'SWITCHING_MEDIA';

    // there is already an outstanding playlist request
    if (request) {
      if (resolveUrl(loader.master.uri, playlist.uri) === request.url) {
        // requesting to switch to the same playlist multiple times
        // has no effect after the first
        return;
      }
      request.onreadystatechange = null;
      request.abort();
      request = null;
    }

    // request the new playlist
    if (this.media_) {
      this.trigger('mediachanging');
    }
    request = this.hls_.xhr({
      uri: resolveUrl(loader.master.uri, playlist.uri),
      withCredentials
    }, function(error, req) {
      // disposed
      if (!request) {
        return;
      }

      if (error) {
        return playlistRequestError(request, playlist.uri, startingState);
      }

      haveMetadata(req, playlist.uri);

      // fire loadedmetadata the first time a media playlist is loaded
      if (startingState === 'HAVE_MASTER') {
        loader.trigger('loadedmetadata');
      } else {
        loader.trigger('mediachange');
      }
    });
  };

  /**
   * set the bandwidth on an xhr to the bandwidth on the playlist
   */
  loader.setBandwidth = function(xhr) {
    loader.bandwidth = xhr.bandwidth;
  };

  // In a live playlist, don't keep track of the expired time
  // until HLS tells us that "first play" has commenced
  loader.on('firstplay', function() {
    this.trackExpiredTime_ = true;
  });

  // live playlist staleness timeout
  loader.on('mediaupdatetimeout', function() {
    if (loader.state !== 'HAVE_METADATA') {
      // only refresh the media playlist if no other activity is going on
      return;
    }

    loader.state = 'HAVE_CURRENT_METADATA';
    request = this.hls_.xhr({
      uri: resolveUrl(loader.master.uri, loader.media().uri),
      withCredentials
    }, function(error, req) {
      // disposed
      if (!request) {
        return;
      }

      if (error) {
        return playlistRequestError(request, loader.media().uri);
      }
      haveMetadata(request, loader.media().uri);
    });
  });

  /**
   * pause loading of the playlist
   */
  loader.pause = () => {
    loader.stopRequest();
    window.clearTimeout(mediaUpdateTimeout);
  };

  /**
   * start loading of the playlist
   */
  loader.load = () => {
    if (loader.started) {
      if (!loader.media().endList) {
        loader.trigger('mediaupdatetimeout');
      } else {
        loader.trigger('loadedplaylist');
      }
    } else {
      loader.start();
    }
  };

  /**
   * start loading of the playlist
   */
  loader.start = () => {
    loader.started = true;

    // request the specified URL
    request = this.hls_.xhr({
      uri: srcUrl,
      withCredentials
    }, function(error, req) {
      let parser;
      let playlist;
      let i;

      // disposed
      if (!request) {
        return;
      }

      // clear the loader's request reference
      request = null;

      if (error) {
        loader.error = {
          status: req.status,
          message: 'HLS playlist request error at URL: ' + srcUrl,
          responseText: req.responseText,
          // MEDIA_ERR_NETWORK
          code: 2
        };
        return loader.trigger('error');
      }

      parser = new m3u8.Parser();
      parser.push(req.responseText);
      parser.end();

      loader.state = 'HAVE_MASTER';

      parser.manifest.uri = srcUrl;

      // loaded a master playlist
      if (parser.manifest.playlists) {
        loader.master = parser.manifest;

        // setup by-URI lookups and resolve media playlist URIs
        i = loader.master.playlists.length;
        while (i--) {
          playlist = loader.master.playlists[i];
          loader.master.playlists[playlist.uri] = playlist;
          playlist.resolvedUri = resolveUrl(loader.master.uri, playlist.uri);
        }

        // resolve any media group URIs
        for (let groupKey in loader.master.mediaGroups.AUDIO) {
          for (let labelKey in loader.master.mediaGroups.AUDIO[groupKey]) {
            let alternateAudio = loader.master.mediaGroups.AUDIO[groupKey][labelKey];

            if (alternateAudio.uri) {
              alternateAudio.resolvedUri =
                resolveUrl(loader.master.uri, alternateAudio.uri);
            }
          }
        }

        loader.trigger('loadedplaylist');
        if (!request) {
          // no media playlist was specifically selected so start
          // from the first listed one
          loader.media(parser.manifest.playlists[0]);
        }
        return;
      }

      // loaded a media playlist
      // infer a master playlist if none was previously requested
      loader.master = {
        mediaGroups: {
          'AUDIO': {},
          'VIDEO': {},
          'CLOSED-CAPTIONS': {},
          'SUBTITLES': {}
        },
        uri: window.location.href,
        playlists: [{
          uri: srcUrl
        }]
      };
      loader.master.playlists[srcUrl] = loader.master.playlists[0];
      loader.master.playlists[0].resolvedUri = srcUrl;
      haveMetadata(req, srcUrl);
      return loader.trigger('loadedmetadata');
    });
  };
};

PlaylistLoader.prototype = new Stream();

 /**
  * Update the PlaylistLoader state to reflect the changes in an
  * update to the current media playlist.
  *
  * @param {Object} update the updated media playlist object
  */
PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) {
  let outdated;
  let i;
  let segment;

  outdated = this.media_;
  this.media_ = this.master.playlists[update.uri];

  if (!outdated) {
    return;
  }

  // don't track expired time until this flag is truthy
  if (!this.trackExpiredTime_) {
    return;
  }

  // if the update was the result of a rendition switch do not
  // attempt to calculate expired_ since media-sequences need not
  // correlate between renditions/variants
  if (update.uri !== outdated.uri) {
    return;
  }

  // try using precise timing from first segment of the updated
  // playlist
  if (update.segments.length) {
    if (typeof update.segments[0].start !== 'undefined') {
      this.expired_ = update.segments[0].start;
      return;
    } else if (typeof update.segments[0].end !== 'undefined') {
      this.expired_ = update.segments[0].end - update.segments[0].duration;
      return;
    }
  }

  // calculate expired by walking the outdated playlist
  i = update.mediaSequence - outdated.mediaSequence - 1;

  for (; i >= 0; i--) {
    segment = outdated.segments[i];

    if (!segment) {
      // we missed information on this segment completely between
      // playlist updates so we'll have to take an educated guess
      // once we begin buffering again, any error we introduce can
      // be corrected
      this.expired_ += outdated.targetDuration || 10;
      continue;
    }

    if (typeof segment.end !== 'undefined') {
      this.expired_ = segment.end;
      return;
    }
    if (typeof segment.start !== 'undefined') {
      this.expired_ = segment.start + segment.duration;
      return;
    }
    this.expired_ += segment.duration;
  }
};

export default PlaylistLoader;