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, EventTarget, log } from 'video.js';
import { isEnabled } from './playlist.js';
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
  */
export const updateSegments = (original, update, offset) => {
  const result = update.slice();

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

  for (let 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.
  */
export const updateMaster = (master, media) => {
  const result = mergeOptions(master, {});
  const playlist = result.playlists.filter((p) => p.uri === media.uri)[0];

  if (!playlist) {
    return null;
  }

  // consider the playlist unchanged if the number of segments is equal and the media
  // sequence number is unchanged
  if (playlist.segments &&
      media.segments &&
      playlist.segments.length === media.segments.length &&
      playlist.mediaSequence === media.mediaSequence) {
    return null;
  }

  const mergedPlaylist = mergeOptions(playlist, media);

  // if the update could overlap existing segment information, merge the two segment lists
  if (playlist.segments) {
    mergedPlaylist.segments = updateSegments(
      playlist.segments,
      media.segments,
      media.mediaSequence - playlist.mediaSequence
    );
  }

  // resolve any segment URIs to prevent us from having to do it later
  mergedPlaylist.segments.forEach((segment) => {
    if (!segment.resolvedUri) {
      segment.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.uri);
    }
    if (segment.key && !segment.key.resolvedUri) {
      segment.key.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.key.uri);
    }
    if (segment.map && !segment.map.resolvedUri) {
      segment.map.resolvedUri = resolveUrl(mergedPlaylist.resolvedUri, segment.map.uri);
    }
  });

  // TODO Right now in the playlists array there are two references to each playlist, one
  // that is referenced by index, and one by URI. The index reference may no longer be
  // necessary.
  for (let i = 0; i < result.playlists.length; i++) {
    if (result.playlists[i].uri === media.uri) {
      result.playlists[i] = mergedPlaylist;
    }
  }
  result.playlists[media.uri] = mergedPlaylist;

  return result;
};

export const setupMediaPlaylists = (master) => {
  // setup by-URI lookups and resolve media playlist URIs
  let i = master.playlists.length;

  while (i--) {
    let playlist = master.playlists[i];

    master.playlists[playlist.uri] = playlist;
    playlist.resolvedUri = resolveUrl(master.uri, playlist.uri);

    if (!playlist.attributes) {
      // Although the spec states an #EXT-X-STREAM-INF tag MUST have a
      // BANDWIDTH attribute, we can play the stream without it. This means a poorly
      // formatted master playlist may not have an attribute list. An attributes
      // property is added here to prevent undefined references when we encounter
      // this scenario.
      playlist.attributes = {};

      log.warn('Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.');
    }
  }
};

export const resolveMediaGroupUris = (master) => {
  ['AUDIO', 'SUBTITLES'].forEach((mediaType) => {
    for (let groupKey in master.mediaGroups[mediaType]) {
      for (let labelKey in master.mediaGroups[mediaType][groupKey]) {
        let mediaProperties = master.mediaGroups[mediaType][groupKey][labelKey];

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

/**
 * Calculates the time to wait before refreshing a live playlist
 *
 * @param {Object} media
 *        The current media
 * @param {Boolean} update
 *        True if there were any updates from the last refresh, false otherwise
 * @return {Number}
 *         The time in ms to wait before refreshing the live playlist
 */
export const refreshDelay = (media, update) => {
  const lastSegment = media.segments[media.segments.length - 1];
  let delay;

  if (update && lastSegment && lastSegment.duration) {
    delay = lastSegment.duration * 1000;
  } else {
    // if the playlist is unchanged since the last reload or last segment duration
    // cannot be determined, try again after half the target duration
    delay = (media.targetDuration || 10) * 500;
  }
  return delay;
};

/**
 * Load a playlist from a remote location
 *
 * @class PlaylistLoader
 * @extends Stream
 * @param {String} srcUrl the url to start with
 * @param {Boolean} withCredentials the withCredentials xhr option
 * @constructor
 */
export default class PlaylistLoader extends EventTarget {
  constructor(srcUrl, hls, withCredentials) {
    super();

    this.srcUrl = srcUrl;
    this.hls_ = hls;
    this.withCredentials = withCredentials;

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

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

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

      this.state = 'HAVE_CURRENT_METADATA';

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

        if (error) {
          return this.playlistRequestError(
            this.request, this.media().uri, 'HAVE_METADATA');
        }

        this.haveMetadata(this.request, this.media().uri);
      });
    });
  }

  playlistRequestError(xhr, url, startingState) {
    // any in-flight request is now finished
    this.request = null;

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

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

    this.trigger('error');
  }

  // update the playlist loader's state in response to a new or
  // updated playlist.
  haveMetadata(xhr, url) {
    // any in-flight request is now finished
    this.request = null;
    this.state = 'HAVE_METADATA';

    const parser = new m3u8.Parser();

    parser.push(xhr.responseText);
    parser.end();
    parser.manifest.uri = url;
    // m3u8-parser does not attach an attributes property to media playlists so make
    // sure that the property is attached to avoid undefined reference errors
    parser.manifest.attributes = parser.manifest.attributes || {};

    // merge this playlist into the master
    const update = updateMaster(this.master, parser.manifest);

    this.targetDuration = parser.manifest.targetDuration;

    if (update) {
      this.master = update;
      this.media_ = this.master.playlists[parser.manifest.uri];
    } else {
      this.trigger('playlistunchanged');
    }

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

    this.trigger('loadedplaylist');
  }

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

  stopRequest() {
    if (this.request) {
      const oldRequest = this.request;

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

  /**
   * Returns the number of enabled playlists on the master playlist object
   *
   * @return {Number} number of eneabled playlists
   */
  enabledPlaylists_() {
    return this.master.playlists.filter(isEnabled).length;
  }

  /**
   * Returns whether the current playlist is the lowest rendition
   *
   * @return {Boolean} true if on lowest rendition
   */
  isLowestEnabledRendition_() {
    if (this.master.playlists.length === 1) {
      return true;
    }

    const currentBandwidth = this.media().attributes.BANDWIDTH || Number.MAX_VALUE;

    return (this.master.playlists.filter((playlist) => {
      if (!isEnabled(playlist)) {
        return false;
      }

      return (playlist.attributes.BANDWIDTH || 0) < currentBandwidth;

    }).length === 0);
  }

  /**
   * Returns whether the current playlist is the final available rendition
   *
   * @return {Boolean} true if on final rendition
   */
  isFinalRendition_() {
    return (this.master.playlists.filter(isEnabled).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=} playlist the parsed media playlist
    * object to switch to
    * @return {Playlist} the current loaded media
    */
  media(playlist) {
    // getter
    if (!playlist) {
      return this.media_;
    }

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

    const startingState = this.state;

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

    const mediaChange = !this.media_ || playlist.uri !== this.media_.uri;

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

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

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

    this.state = 'SWITCHING_MEDIA';

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

    // request the new playlist
    if (this.media_) {
      this.trigger('mediachanging');
    }

    this.request = this.hls_.xhr({
      uri: resolveUrl(this.master.uri, playlist.uri),
      withCredentials: this.withCredentials
    }, (error, req) => {
      // disposed
      if (!this.request) {
        return;
      }

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

      this.haveMetadata(req, playlist.uri);

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

  /**
   * pause loading of the playlist
   */
  pause() {
    this.stopRequest();
    window.clearTimeout(this.mediaUpdateTimeout);
    if (this.state === 'HAVE_NOTHING') {
      // If we pause the loader before any data has been retrieved, its as if we never
      // started, so reset to an unstarted state.
      this.started = false;
    }
    // Need to restore state now that no activity is happening
    if (this.state === 'SWITCHING_MEDIA') {
      // if the loader was in the process of switching media, it should either return to
      // HAVE_MASTER or HAVE_METADATA depending on if the loader has loaded a media
      // playlist yet. This is determined by the existence of loader.media_
      if (this.media_) {
        this.state = 'HAVE_METADATA';
      } else {
        this.state = 'HAVE_MASTER';
      }
    } else if (this.state === 'HAVE_CURRENT_METADATA') {
      this.state = 'HAVE_METADATA';
    }
  }

  /**
   * start loading of the playlist
   */
  load(isFinalRendition) {
    window.clearTimeout(this.mediaUpdateTimeout);

    const media = this.media();

    if (isFinalRendition) {
      const delay = media ? (media.targetDuration / 2) * 1000 : 5 * 1000;

      this.mediaUpdateTimeout = window.setTimeout(() => this.load(), delay);
      return;
    }

    if (!this.started) {
      this.start();
      return;
    }

    if (media && !media.endList) {
      this.trigger('mediaupdatetimeout');
    } else {
      this.trigger('loadedplaylist');
    }
  }

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

    // request the specified URL
    this.request = this.hls_.xhr({
      uri: this.srcUrl,
      withCredentials: this.withCredentials
    }, (error, req) => {
      // disposed
      if (!this.request) {
        return;
      }

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

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

      const parser = new m3u8.Parser();

      parser.push(req.responseText);
      parser.end();

      this.state = 'HAVE_MASTER';

      parser.manifest.uri = this.srcUrl;

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

        setupMediaPlaylists(this.master);
        resolveMediaGroupUris(this.master);

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

      // loaded a media playlist
      // infer a master playlist if none was previously requested
      this.master = {
        mediaGroups: {
          'AUDIO': {},
          'VIDEO': {},
          'CLOSED-CAPTIONS': {},
          'SUBTITLES': {}
        },
        uri: window.location.href,
        playlists: [{
          uri: this.srcUrl
        }]
      };
      this.master.playlists[this.srcUrl] = this.master.playlists[0];
      this.master.playlists[0].resolvedUri = this.srcUrl;
      // m3u8-parser does not attach an attributes property to media playlists so make
      // sure that the property is attached to avoid undefined reference errors
      this.master.playlists[0].attributes = this.master.playlists[0].attributes || {};
      this.haveMetadata(req, this.srcUrl);
      return this.trigger('loadedmetadata');
    });
  }
}