Source: lib/util/content_steering_manager.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.util.ContentSteeringManager');

goog.require('goog.Uri');
goog.require('shaka.media.ManifestParser');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.util.Error');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.OperationManager');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');


/**
 * Create a Content Steering manager.
 *
 * @implements {shaka.util.IDestroyable}
 */
shaka.util.ContentSteeringManager = class {
  /**
   * @param {shaka.extern.ManifestParser.PlayerInterface} playerInterface
   */
  constructor(playerInterface) {
    /** @private {?shaka.extern.ManifestConfiguration} */
    this.config_ = null;

    /** @private {?shaka.extern.ManifestParser.PlayerInterface} */
    this.playerInterface_ = playerInterface;

    /** @private {!shaka.util.OperationManager} */
    this.operationManager_ = new shaka.util.OperationManager();

    /** @private {!Array.<string>} */
    this.baseUris_ = [];

    /** @private {?string} */
    this.defaultPathwayId_ = null;

    /** @private {!Array.<string>} */
    this.pathwayPriority_ = [];

    /** @private {?string} */
    this.lastPathwayUsed_ = null;

    /** @private {!Array.<shaka.util.ContentSteeringManager.PathawayClone>} */
    this.pathwayClones_ = [];

    /**
     * Default to 5 minutes. Value in seconds.
     *
     * @private {number}
     */
    this.lastTTL_ = 300;

    /** @private {!Map.<(string|number), !Map.<string, string>>} */
    this.locations_ = new Map();

    /** @private {!Map.<string, number>} */
    this.bannedLocations_ = new Map();

    /** @private {?shaka.util.Timer} */
    this.updateTimer_ = null;

    /** @private {string} */
    this.manifestType_ = shaka.media.ManifestParser.UNKNOWN;
  }

  /**
   * @param {shaka.extern.ManifestConfiguration} config
   */
  configure(config) {
    this.config_ = config;
  }


  /** @override */
  destroy() {
    this.config_ = null;
    this.playerInterface_ = null;
    this.baseUris_ = [];
    this.defaultPathwayId_ = null;
    this.pathwayPriority_ = [];
    this.pathwayClones_ = [];
    this.locations_.clear();

    if (this.updateTimer_ != null) {
      this.updateTimer_.stop();
      this.updateTimer_ = null;
    }

    return this.operationManager_.destroy();
  }

  /**
   * @param {string} manifestType
   */
  setManifestType(manifestType) {
    this.manifestType_ = manifestType;
  }

  /**
   * @param {!Array.<string>} baseUris
   */
  setBaseUris(baseUris) {
    this.baseUris_ = baseUris;
  }

  /**
   * @param {?string} defaultPathwayId
   */
  setDefaultPathwayId(defaultPathwayId) {
    this.defaultPathwayId_ = defaultPathwayId;
  }

  /**
   * Request the Content Steering info.
   *
   * @param {string} uri
   * @return {!Promise}
   */
  async requestInfo(uri) {
    const uris = shaka.util.ManifestParserUtils.resolveUris(
        this.baseUris_, [this.addQueryParams_(uri)]);
    const type = shaka.net.NetworkingEngine.RequestType.CONTENT_STEERING;
    const request = shaka.net.NetworkingEngine.makeRequest(
        uris, this.config_.retryParameters);

    const op = this.playerInterface_.networkingEngine.request(type, request);
    this.operationManager_.manage(op);
    try {
      const response = await op.promise;
      const str = shaka.util.StringUtils.fromUTF8(response.data);
      const steeringManifest =
          /** @type {shaka.util.ContentSteeringManager.SteeringManifest} */
          (JSON.parse(str));
      if (steeringManifest.VERSION == 1) {
        this.processManifest_(steeringManifest, response.uri);
      }
    } catch (e) {
      if (e && e.code == shaka.util.Error.Code.OPERATION_ABORTED) {
        return;
      }
      if (this.updateTimer_ != null) {
        this.updateTimer_.stop();
        this.updateTimer_ = null;
      }
      this.updateTimer_ = new shaka.util.Timer(() => {
        this.requestInfo(uri);
      });
      this.updateTimer_.tickAfter(this.lastTTL_);
    }
  }

  /** @private */
  addQueryParams_(uri) {
    if (!this.pathwayPriority_.length) {
      return uri;
    }
    const finalUri = new goog.Uri(uri);
    const currentPathwayID = this.lastPathwayUsed_ || this.pathwayPriority_[0];
    const currentBandwidth =
        Math.round(this.playerInterface_.getBandwidthEstimate());
    const queryData = finalUri.getQueryData();
    if (this.manifestType_ == shaka.media.ManifestParser.DASH) {
      queryData.add('_DASH_pathway', currentPathwayID);
      queryData.add('_DASH_throughput', String(currentBandwidth));
    } else if (this.manifestType_ == shaka.media.ManifestParser.HLS) {
      queryData.add('_HLS_pathway', currentPathwayID);
      queryData.add('_HLS_throughput', String(currentBandwidth));
    }
    if (queryData.getCount()) {
      finalUri.setQueryData(queryData);
    }
    return finalUri.toString();
  }

  /**
   * @param {shaka.util.ContentSteeringManager.SteeringManifest} manifest
   * @param {string} finalManifestUri
   * @private
   */
  processManifest_(manifest, finalManifestUri) {
    if (this.updateTimer_ != null) {
      this.updateTimer_.stop();
      this.updateTimer_ = null;
    }
    const uri = manifest['RELOAD-URI'] || finalManifestUri;
    this.updateTimer_ = new shaka.util.Timer(() => {
      this.requestInfo(uri);
    });
    const newTTL = manifest['TTL'];
    if (newTTL) {
      this.lastTTL_ = newTTL;
    }
    this.updateTimer_.tickAfter(this.lastTTL_);
    this.pathwayPriority_ = manifest['PATHWAY-PRIORITY'] || [];
    this.pathwayClones_ = manifest['PATHWAY-CLONES'] || [];
  }

  /**
   * Clear the previous locations added.
   */
  clearPreviousLocations() {
    this.locations_.clear();
  }

  /**
   * @param {string|number} streamId
   * @param {string} pathwayId
   * @param {string} uri
   */
  addLocation(streamId, pathwayId, uri) {
    let streamLocations = this.locations_.get(streamId);
    if (!streamLocations) {
      streamLocations = new Map();
    }
    streamLocations.set(pathwayId, uri);
    this.locations_.set(streamId, streamLocations);
  }

  /**
   * @param {string} uri
   */
  banLocation(uri) {
    const bannedUntil = Date.now() + 60000;
    this.bannedLocations_.set(uri, bannedUntil);
  }

  /**
   * Get the base locations ordered according the priority.
   *
   * @param {string|number} streamId
   * @return {!Array.<string>}
   */
  getLocations(streamId) {
    const streamLocations = this.locations_.get(streamId) || new Map();
    /** @type {!Array.<!{pathwayId: string, location: string}>} */
    let locationsPathwayIdMap = [];
    for (const pathwayId of this.pathwayPriority_) {
      const location = streamLocations.get(pathwayId);
      if (location) {
        locationsPathwayIdMap.push({pathwayId, location});
      } else {
        const clone = this.pathwayClones_.find((c) => c.ID == pathwayId);
        if (clone) {
          const cloneLocation = streamLocations.get(clone['BASE-ID']);
          if (cloneLocation) {
            if (clone['URI-REPLACEMENT'].HOST) {
              const uri = new goog.Uri(cloneLocation);
              uri.setDomain(clone['URI-REPLACEMENT'].HOST);
              locationsPathwayIdMap.push({
                pathwayId: pathwayId,
                location: uri.toString(),
              });
            } else {
              locationsPathwayIdMap.push({
                pathwayId: pathwayId,
                location: cloneLocation,
              });
            }
          }
        }
      }
    }

    const now = Date.now();
    for (const uri of this.bannedLocations_.keys()) {
      const bannedUntil = this.bannedLocations_.get(uri);
      if (now > bannedUntil) {
        this.bannedLocations_.delete(uri);
      }
    }
    locationsPathwayIdMap = locationsPathwayIdMap.filter((l) => {
      for (const uri of this.bannedLocations_.keys()) {
        if (uri.includes(new goog.Uri(l.location).getDomain())) {
          return false;
        }
      }
      return true;
    });

    if (locationsPathwayIdMap.length) {
      this.lastPathwayUsed_ = locationsPathwayIdMap[0].pathwayId;
    }

    const locations = locationsPathwayIdMap.map((l) => l.location);

    if (!locations.length && this.defaultPathwayId_) {
      for (const pathwayId of this.defaultPathwayId_.split(',')) {
        const location = streamLocations.get(pathwayId);
        if (location) {
          this.lastPathwayUsed_ = this.defaultPathwayId_;
          locations.push(location);
        }
      }
    }
    if (!locations.length) {
      for (const location of streamLocations.values()) {
        locations.push(location);
      }
    }
    return shaka.util.ManifestParserUtils.resolveUris(
        this.baseUris_, locations);
  }
};


/**
 * @typedef {{
 *   VERSION: number,
 *   TTL: number,
 *   RELOAD-URI: string,
 *   PATHWAY-PRIORITY: !Array.<string>,
 *   PATHWAY-CLONES: !Array.<shaka.util.ContentSteeringManager.PathawayClone>
 * }}
 *
 * @description
 * Contains information about the Steering Manifest
 *
 * @property {string} VERSION
 * @property {number} TTL
 * @property {string RELOAD-URI
 * @property {!Array.<string>} PATHWAY-PRIORITY
 * @property {!Array.<
 *            shaka.util.ContentSteeringManager.PathawayClone>} PATHWAY-CLONES
 */
shaka.util.ContentSteeringManager.SteeringManifest;


/**
 * @typedef {{
 *   BASE-ID: string,
 *   ID: string,
 *   URI-REPLACEMENT: !Array.<shaka.util.ContentSteeringManager.UriReplacement>
 * }}
 *
 * @property {string} BASE-ID
 * @property {string} ID
 * @property {!Array.<
 *            shaka.util.ContentSteeringManager.UriReplacement>} URI-REPLACEMENT
 */
shaka.util.ContentSteeringManager.PathawayClone;


/**
 * @typedef {{
 *   HOST: string
 * }}
 *
 * @property {string} HOST
 */
shaka.util.ContentSteeringManager.UriReplacement;